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//! * **Phase 3** — structural LICQ check on the surviving equality
13//!   rows. Verdict is published via [`PresolveTnlp::licq_verdict`].
14//! * **Phase 4** — bound-multiplier warm-start hints for variables
15//!   whose bounds were tightened by Phase 1. Hints are emitted on
16//!   `init_z` and exposed via [`PresolveTnlp::z_warm_starts`].
17//! * **Phase 5** — sensitivity-aware passthrough: projects
18//!   user-supplied constraint metadata and scaling through the row
19//!   reduction on the way in, and expands outer→inner on the way out
20//!   in `finalize_metadata`.
21
22#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
23
24use std::cell::RefCell;
25use std::rc::Rc;
26
27use pounce_common::exception::SolverException;
28use pounce_common::options_list::OptionsList;
29use pounce_common::reg_options::RegisteredOptions;
30use pounce_common::types::{Index, Number};
31use pounce_nlp::expression_provider::ExpressionProvider;
32use pounce_nlp::tnlp::{
33    BoundsInfo, IndexStyle, IpoptCq, IpoptData, IterStats, Linearity, MetaData, NlpInfo,
34    ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
35};
36
37pub mod auxiliary;
38pub mod block_solve;
39pub mod bound_tighten;
40pub mod btf;
41pub mod components;
42pub mod coupling;
43pub mod diagnostics;
44pub mod dulmage_mendelsohn;
45pub mod fbbt;
46pub mod incidence;
47pub mod inequality_projection;
48pub mod licq;
49pub mod matching;
50pub mod options;
51pub mod reduction_frame;
52pub mod redundant;
53pub mod trivial_elim;
54
55pub use block_solve::{
56    BlockEquations, BlockSolveError, BlockSolveOptions, BlockSolveOutcome, BlockSolver,
57    DampedNewtonSolver,
58};
59pub use bound_tighten::{tighten_bounds, LinearRow, TightenReport, INF_BOUND};
60pub use btf::{BlockTriangularBlock, BlockTriangularForm};
61pub use components::{SquareComponent, SquareComponents};
62pub use coupling::{classify_block, objective_gradient_support, AuxiliaryCouplingClass};
63pub use diagnostics::{AuxiliaryPreprocessingDiagnostics, AuxiliaryRejectionReason};
64pub use dulmage_mendelsohn::{DMPart, DulmageMendelsohnPartition};
65pub use incidence::{EqualityIncidence, InequalityIncidence, ProbeView};
66pub use licq::{licq_check, EqRow, LicqVerdict};
67pub use options::{register_options, AuxiliaryCouplingPolicy, LicqAction, PresolveOptions};
68pub use reduction_frame::{ReductionFrame, ReductionStack};
69pub use redundant::find_redundant_rows;
70
71/// Errors that can arise while building a presolved TNLP.
72#[derive(Debug)]
73pub enum PresolveError {
74    OptionsError(SolverException),
75}
76
77impl std::fmt::Display for PresolveError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::OptionsError(e) => write!(f, "presolve options error: {e}"),
81        }
82    }
83}
84
85impl std::error::Error for PresolveError {}
86
87impl From<SolverException> for PresolveError {
88    fn from(e: SolverException) -> Self {
89        Self::OptionsError(e)
90    }
91}
92
93/// Top-level entry: returns a TNLP wrapping `inner` with whatever
94/// presolve passes the option table has enabled. When the master
95/// switch is off, returns `inner` unchanged.
96pub fn wrap_with_presolve(
97    inner: Rc<RefCell<dyn TNLP>>,
98    opts: PresolveOptions,
99) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
100    if !opts.enabled {
101        return Ok(inner);
102    }
103    Ok(Rc::new(RefCell::new(PresolveTnlp::new(inner, opts))))
104}
105
106/// Same as [`wrap_with_presolve`] but also installs an
107/// [`ExpressionProvider`] so passes like FBBT (issue #62) can see
108/// constraint expression trees. Callers who have the concrete inner
109/// TNLP type (`pounce-cli` with `NlTnlp`) should prefer this; the
110/// plain `wrap_with_presolve` leaves `presolve_fbbt` as a silent
111/// no-op.
112pub fn wrap_with_presolve_provider(
113    inner: Rc<RefCell<dyn TNLP>>,
114    expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
115    opts: PresolveOptions,
116) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
117    if !opts.enabled {
118        return Ok(inner);
119    }
120    Ok(Rc::new(RefCell::new(
121        PresolveTnlp::with_expression_provider(inner, expr_provider, opts),
122    )))
123}
124
125/// Convenience: read the `presolve_*` keys out of an `OptionsList`
126/// and call [`wrap_with_presolve`].
127pub fn wrap_from_options(
128    inner: Rc<RefCell<dyn TNLP>>,
129    options: &OptionsList,
130) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
131    let opts = PresolveOptions::from_options_list(options)?;
132    wrap_with_presolve(inner, opts)
133}
134
135/// Cached, reduced view of the problem after presolve passes have
136/// run. Exposed for inspection from integration tests.
137pub struct CachedBounds {
138    pub x_l: Vec<Number>,
139    pub x_u: Vec<Number>,
140    /// Reduced (post-row-drop) constraint lower bounds.
141    pub g_l: Vec<Number>,
142    /// Reduced constraint upper bounds.
143    pub g_u: Vec<Number>,
144}
145
146/// TNLP wrapper that re-presents the inner problem after presolve.
147pub struct PresolveTnlp {
148    inner: Rc<RefCell<dyn TNLP>>,
149    /// Optional structural-expression handle on the inner TNLP for
150    /// passes (FBBT, issue #62) that need DAG-level access. Callers
151    /// who know the concrete inner type (e.g. `pounce-cli` with
152    /// `NlTnlp`) install this via
153    /// [`Self::with_expression_provider`]. Callers without (e.g.
154    /// callback-based bridges) leave it `None` and the expression-
155    /// hungry passes silently become no-ops.
156    expr_provider: Option<Rc<RefCell<dyn ExpressionProvider>>>,
157    opts: PresolveOptions,
158
159    /// `None` until init has run; afterwards `Some(state)`.
160    state: Option<PresolveState>,
161}
162
163struct PresolveState {
164    info_inner: NlpInfo,
165    info_outer: NlpInfo,
166    bounds: CachedBounds,
167
168    /// Maps outer (reduced) row index → inner row index. Length
169    /// equals `info_outer.m`.
170    rows_kept: Vec<usize>,
171
172    /// For each outer nnz, the position in the inner nnz array.
173    jac_kept_idx: Vec<usize>,
174    /// Cached outer (reduced + renumbered) Jacobian sparsity, served
175    /// on `eval_jac_g(Structure)`.
176    jac_irow_outer: Vec<Index>,
177    jac_jcol_outer: Vec<Index>,
178
179    /// Phase 1 report.
180    tighten_report: TightenReport,
181    /// FBBT report (`None` when `presolve_fbbt` was off or the inner
182    /// TNLP did not expose an `ExpressionProvider`).
183    fbbt_report: Option<crate::fbbt::FbbtReport>,
184    /// Number of rows dropped by Phase 2.
185    n_dropped_rows: Index,
186    /// Phase 3 verdict (`None` if the LICQ check was disabled).
187    licq_verdict: Option<LicqVerdict>,
188    /// Phase 4: warm-start values for `z_l` per variable. Entry is
189    /// 0.0 where presolve did not tighten the lower bound, else
190    /// `bound_mult_init_val`. Same length as `bounds.x_l`.
191    z_l_warm: Vec<Number>,
192    /// Phase 4: warm-start values for `z_u` per variable.
193    z_u_warm: Vec<Number>,
194
195    /// Scratch buffers reused across eval_* calls.
196    scratch_g: Vec<Number>,
197    scratch_jac: Vec<Number>,
198    scratch_lambda: Vec<Number>,
199
200    /// Phase 0 (issue #53) diagnostics. Always present; defaulted to
201    /// zeros when the master switch is off.
202    aux_diagnostics: AuxiliaryPreprocessingDiagnostics,
203    /// Phase 0 postsolve stack. Empty until PR 7 wires real frames.
204    #[allow(dead_code)]
205    reduction_stack: ReductionStack,
206}
207
208impl PresolveTnlp {
209    /// Build a presolve wrapper directly. Prefer
210    /// [`wrap_with_presolve`] in production code; this constructor
211    /// is exposed so integration tests can keep a typed handle for
212    /// accessors like [`Self::licq_verdict`].
213    pub fn new(inner: Rc<RefCell<dyn TNLP>>, opts: PresolveOptions) -> Self {
214        Self {
215            inner,
216            expr_provider: None,
217            opts,
218            state: None,
219        }
220    }
221
222    /// Build a presolve wrapper with an `ExpressionProvider` handle on
223    /// the same inner TNLP. The two handles should reference the
224    /// *same* object (typical pattern: clone an `Rc<RefCell<NlTnlp>>`
225    /// twice, once as `dyn TNLP` and once as `dyn ExpressionProvider`).
226    /// Required for `presolve_fbbt=yes` to fire — without a provider,
227    /// FBBT silently becomes a no-op.
228    pub fn with_expression_provider(
229        inner: Rc<RefCell<dyn TNLP>>,
230        expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
231        opts: PresolveOptions,
232    ) -> Self {
233        Self {
234            inner,
235            expr_provider: Some(expr_provider),
236            opts,
237            state: None,
238        }
239    }
240
241    /// FBBT report (`None` until init runs, or when FBBT was disabled
242    /// or the inner TNLP did not expose an `ExpressionProvider`).
243    pub fn fbbt_report(&self) -> Option<crate::fbbt::FbbtReport> {
244        self.state.as_ref().and_then(|s| s.fbbt_report.clone())
245    }
246
247    /// Phase 1 report (zeroed until init has run).
248    pub fn tighten_report(&self) -> TightenReport {
249        self.state
250            .as_ref()
251            .map(|s| s.tighten_report.clone())
252            .unwrap_or_default()
253    }
254
255    /// Number of constraint rows dropped by Phase 2 (0 if presolve
256    /// has not yet run or no rows are redundant).
257    pub fn n_dropped_rows(&self) -> Index {
258        self.state.as_ref().map(|s| s.n_dropped_rows).unwrap_or(0)
259    }
260
261    /// Cached reduced bounds, if presolve has run.
262    pub fn cached_bounds(&self) -> Option<&CachedBounds> {
263        self.state.as_ref().map(|s| &s.bounds)
264    }
265
266    /// Phase 3 verdict — `Some` only if the LICQ check was enabled
267    /// and presolve has run.
268    pub fn licq_verdict(&self) -> Option<&LicqVerdict> {
269        self.state.as_ref().and_then(|s| s.licq_verdict.as_ref())
270    }
271
272    /// Phase 4 warm-start hints `(z_l, z_u)`. Each entry is 0.0 if
273    /// no hint is set for that variable, else the configured
274    /// `bound_mult_init_val`. `None` until init has run.
275    pub fn z_warm_starts(&self) -> Option<(&[Number], &[Number])> {
276        self.state
277            .as_ref()
278            .map(|s| (&s.z_l_warm[..], &s.z_u_warm[..]))
279    }
280
281    /// Phase 0 (issue #53) summary. Returns a zero-valued struct
282    /// until init has run; afterwards, populated by
283    /// [`auxiliary::run_auxiliary_phase0`]. PR 1 always returns
284    /// zeros even with the master switch on.
285    pub fn auxiliary_diagnostics(&self) -> AuxiliaryPreprocessingDiagnostics {
286        self.state
287            .as_ref()
288            .map(|s| s.aux_diagnostics.clone())
289            .unwrap_or_default()
290    }
291
292    /// Lazy initialization: pull inner dims, bounds, linearity tags,
293    /// Jacobian, and starting point; run Phase 1 + Phase 2 passes;
294    /// cache everything needed to translate later eval_* calls.
295    fn ensure_init(&mut self) -> Option<&PresolveState> {
296        if self.state.is_some() {
297            return self.state.as_ref();
298        }
299
300        let info_inner = self.inner.borrow_mut().get_nlp_info()?;
301        let n = info_inner.n as usize;
302        let m_in = info_inner.m as usize;
303        let nnz_in = info_inner.nnz_jac_g as usize;
304
305        // Inner bounds.
306        let mut x_l = vec![0.0; n];
307        let mut x_u = vec![0.0; n];
308        let mut g_l_inner = vec![0.0; m_in];
309        let mut g_u_inner = vec![0.0; m_in];
310        {
311            let mut inner = self.inner.borrow_mut();
312            if !inner.get_bounds_info(BoundsInfo {
313                x_l: &mut x_l,
314                x_u: &mut x_u,
315                g_l: &mut g_l_inner,
316                g_u: &mut g_u_inner,
317            }) {
318                return None;
319            }
320        }
321
322        // Jacobian sparsity.
323        let mut jac_irow_inner = vec![0 as Index; nnz_in];
324        let mut jac_jcol_inner = vec![0 as Index; nnz_in];
325        if nnz_in > 0 {
326            let mut inner = self.inner.borrow_mut();
327            if !inner.eval_jac_g(
328                None,
329                false,
330                SparsityRequest::Structure {
331                    irow: &mut jac_irow_inner,
332                    jcol: &mut jac_jcol_inner,
333                },
334            ) {
335                return None;
336            }
337        }
338
339        // Linearity tags (presolve is dormant without them).
340        let mut linearity = vec![Linearity::NonLinear; m_in];
341        let have_linearity = if m_in > 0 {
342            self.inner
343                .borrow_mut()
344                .get_constraints_linearity(&mut linearity)
345        } else {
346            true
347        };
348
349        // Probe point for Jacobian values (linear rows have constant
350        // Jacobians; this `x` is only needed because some inner
351        // TNLPs assert on receipt).
352        let mut x_probe = vec![0.0; n];
353        let mut z_l_probe = vec![0.0; n];
354        let mut z_u_probe = vec![0.0; n];
355        let mut lambda_probe = vec![0.0; m_in];
356        let started = self.inner.borrow_mut().get_starting_point(StartingPoint {
357            init_x: true,
358            x: &mut x_probe,
359            init_z: false,
360            z_l: &mut z_l_probe,
361            z_u: &mut z_u_probe,
362            init_lambda: false,
363            lambda: &mut lambda_probe,
364        });
365        if !started {
366            return None;
367        }
368
369        // Jacobian values at the probe.
370        let mut jac_values_inner = vec![0.0; nnz_in];
371        if nnz_in > 0 {
372            let ok = self.inner.borrow_mut().eval_jac_g(
373                Some(&x_probe),
374                true,
375                SparsityRequest::Values {
376                    values: &mut jac_values_inner,
377                },
378            );
379            if !ok {
380                return None;
381            }
382        }
383
384        // Build LinearRow list from the inner Jacobian + linearity.
385        let one_based = matches!(info_inner.index_style, IndexStyle::Fortran);
386        let mut by_row: Vec<Vec<(Index, Number)>> = vec![Vec::new(); m_in];
387        for k in 0..nnz_in {
388            let i = if one_based {
389                (jac_irow_inner[k] - 1) as usize
390            } else {
391                jac_irow_inner[k] as usize
392            };
393            let j = if one_based {
394                jac_jcol_inner[k] - 1
395            } else {
396                jac_jcol_inner[k]
397            };
398            if i < m_in && (j as usize) < n {
399                by_row[i].push((j, jac_values_inner[k]));
400            }
401        }
402        let linear_row_map: Vec<Option<LinearRow>> = (0..m_in)
403            .map(|i| {
404                if have_linearity && matches!(linearity[i], Linearity::Linear) {
405                    Some(LinearRow {
406                        entries: by_row[i].clone(),
407                        lo: g_l_inner[i],
408                        hi: g_u_inner[i],
409                    })
410                } else {
411                    None
412                }
413            })
414            .collect();
415        // NOTE: `linear_rows` is materialised AFTER Phase 0 so it
416        // can filter out rows Phase 0 dropped — propagating bounds
417        // through aux-eliminated rows lets tighten_bounds derive
418        // contradictions (see issue #53 PR review).
419
420        // Snapshot inner bounds before Phase 1 mutates them; needed
421        // for Phase 4 warm-start hints AND for rolling back Phase 0
422        // if its clamps later prove infeasible against the kept
423        // linear rows.
424        let inner_x_l = x_l.clone();
425        let inner_x_u = x_u.clone();
426
427        // Phase 0 (issue #53): auxiliary-equality preprocessing.
428        // PR 8 wires the real pipeline (incidence → matching → DM →
429        // BTF → classify → linear block solve → frame). Variables it
430        // fixes are clamped via x_l/x_u; rows it drops are recorded
431        // in `row_kept_inner` so the existing remapping logic below
432        // picks them up.
433        let mut row_kept_inner: Vec<bool> = vec![true; m_in];
434        let mut reduction_stack = ReductionStack::default();
435        let aux_diagnostics = if self.opts.auxiliary && m_in > 0 {
436            // Probe extra quantities Phase 0 needs.
437            let mut g_at_probe = vec![0.0; m_in];
438            let g_ok = self
439                .inner
440                .borrow_mut()
441                .eval_g(&x_probe, true, &mut g_at_probe);
442            if !g_ok {
443                return None;
444            }
445            let mut grad_f_probe = vec![0.0; n];
446            let grad_ok = self
447                .inner
448                .borrow_mut()
449                .eval_grad_f(&x_probe, false, &mut grad_f_probe);
450            if !grad_ok {
451                return None;
452            }
453            // Plug everything into the orchestrator.
454            let linearity_for_phase0: Vec<Linearity> = if have_linearity {
455                linearity.clone()
456            } else {
457                vec![Linearity::NonLinear; m_in]
458            };
459            let probe_view = auxiliary::Phase0Probe {
460                n_vars: n,
461                n_rows: m_in,
462                jac_irow: &jac_irow_inner,
463                jac_jcol: &jac_jcol_inner,
464                jac_values: &jac_values_inner,
465                g_l: &g_l_inner,
466                g_u: &g_u_inner,
467                g_at_probe: &g_at_probe,
468                linearity: &linearity_for_phase0,
469                one_based,
470                eq_tol: 1e-12,
471                x_probe: &x_probe,
472                grad_f: &grad_f_probe,
473                x_l: &x_l,
474                x_u: &x_u,
475            };
476            // Adapter: wrap `self.inner` so the orchestrator can
477            // call eval_g / eval_jac_g for nonlinear blocks.
478            struct TnlpCallbackAdapter {
479                inner: Rc<RefCell<dyn TNLP>>,
480            }
481            impl auxiliary::Phase0TnlpCallback for TnlpCallbackAdapter {
482                fn eval_g_full(&mut self, x: &[Number], g: &mut [Number]) -> bool {
483                    self.inner.borrow_mut().eval_g(x, true, g)
484                }
485                fn eval_jac_g_values(&mut self, x: &[Number], values: &mut [Number]) -> bool {
486                    self.inner.borrow_mut().eval_jac_g(
487                        Some(x),
488                        true,
489                        SparsityRequest::Values { values },
490                    )
491                }
492            }
493            let mut adapter = TnlpCallbackAdapter {
494                inner: Rc::clone(&self.inner),
495            };
496            let mut large_solver = block_solve::RelaxedNewtonSolver;
497            let plan = auxiliary::run_auxiliary_phase0(
498                &self.opts,
499                &probe_view,
500                Some(&mut adapter),
501                Some(&mut large_solver),
502            );
503            if let Some(frame) = plan.frame {
504                // Clamp fixed variables.
505                for (k, &i) in frame.fixed_vars.iter().enumerate() {
506                    x_l[i] = frame.fixed_values[k];
507                    x_u[i] = frame.fixed_values[k];
508                }
509                // Drop dropped rows.
510                for &r in &frame.dropped_rows {
511                    row_kept_inner[r] = false;
512                }
513                reduction_stack.push(frame);
514            }
515            // When `presolve_auxiliary_diagnostics=yes`, emit the
516            // summary through tracing (pounce#71).
517            if self.opts.auxiliary_diagnostics {
518                tracing::info!(target: "pounce::presolve", "{}", plan.diagnostics);
519            }
520            plan.diagnostics
521        } else {
522            AuxiliaryPreprocessingDiagnostics::default()
523        };
524
525        // Build `linear_rows` excluding rows Phase 0 dropped. This
526        // is the headline fix from the #53 PR review: propagating
527        // bounds through an aux-dropped row lets tighten_bounds
528        // derive `x_l[j] > x_u[j]` for an aux-clamped variable and
529        // then hand corrupted bounds to the IPM.
530        let linear_rows: Vec<LinearRow> = linear_row_map
531            .iter()
532            .enumerate()
533            .filter_map(|(i, r)| if row_kept_inner[i] { r.clone() } else { None })
534            .collect();
535
536        // Phase 1: bound tightening using linear rows.
537        let mut tighten_report = TightenReport::default();
538        if self.opts.bound_tightening && !linear_rows.is_empty() {
539            tighten_report = tighten_bounds(
540                &linear_rows,
541                &mut x_l,
542                &mut x_u,
543                self.opts.max_passes,
544                1e-12,
545            );
546        }
547
548        // Defence in depth: if Phase 1 still flags infeasibility AND
549        // Phase 0 made changes, those changes are presumed to blame
550        // (aux solved a block to a point inconsistent with bounds
551        // from kept rows). Roll back Phase 0 — restore bounds, undo
552        // row drops, clear the reduction stack — and re-run Phase 1
553        // on the un-filtered linear rows. Without this guard,
554        // `report.infeasible` was previously never inspected and
555        // corrupted bounds reached the IPM (#53 PR review).
556        if tighten_report.infeasible && !reduction_stack.is_empty() {
557            tracing::warn!(
558                target: "pounce::presolve",
559                "auxiliary-equality elimination produced bounds inconsistent \
560                 with kept linear rows; rolling back the elimination for this solve."
561            );
562            x_l.copy_from_slice(&inner_x_l);
563            x_u.copy_from_slice(&inner_x_u);
564            for kept in row_kept_inner.iter_mut() {
565                *kept = true;
566            }
567            reduction_stack = ReductionStack::default();
568            // Re-run tighten on the unfiltered linear rows now that
569            // the aux clamps are gone.
570            let full_linear_rows: Vec<LinearRow> =
571                linear_row_map.iter().filter_map(|r| r.clone()).collect();
572            tighten_report = TightenReport::default();
573            if self.opts.bound_tightening && !full_linear_rows.is_empty() {
574                tighten_report = tighten_bounds(
575                    &full_linear_rows,
576                    &mut x_l,
577                    &mut x_u,
578                    self.opts.max_passes,
579                    1e-12,
580                );
581            }
582        }
583
584        // Phase 1b — FBBT (issue #62). Runs interval arithmetic over
585        // each nonlinear constraint's expression DAG to tighten
586        // variable bounds further. No-op when (a) `presolve_fbbt` is
587        // off, (b) the inner TNLP did not supply an
588        // `ExpressionProvider`, or (c) the problem has zero
589        // constraints. Honors `fbbt_tol`, `fbbt_max_iter`, and
590        // `fbbt_max_constraints`.
591        let mut fbbt_report: Option<crate::fbbt::FbbtReport> = None;
592        if self.opts.fbbt && m_in > 0 {
593            if let Some(provider) = self.expr_provider.as_ref() {
594                let cfg = crate::fbbt::FbbtConfig {
595                    tol: self.opts.fbbt_tol,
596                    max_iter: self.opts.fbbt_max_iter.max(1) as usize,
597                    max_constraints: self.opts.fbbt_max_constraints.max(0) as usize,
598                };
599                let provider_borrow = provider.borrow();
600                let report = crate::fbbt::run_fbbt(
601                    &*provider_borrow,
602                    n,
603                    m_in,
604                    &mut x_l,
605                    &mut x_u,
606                    &g_l_inner,
607                    &g_u_inner,
608                    &cfg,
609                );
610                fbbt_report = Some(report);
611            }
612        }
613
614        // Phase 4: any variable whose lower (upper) bound moved
615        // strictly inward is a candidate for a bound-multiplier warm
616        // start. Zero entries leave that bound's multiplier on the
617        // global default (`bound_mult_init_val` from upstream).
618        let warm_tol: Number = 1e-12;
619        let (z_l_warm, z_u_warm) = if self.opts.warm_z_bounds {
620            let v0 = self.opts.bound_mult_init_val;
621            let mut zl = vec![0.0; n];
622            let mut zu = vec![0.0; n];
623            for i in 0..n {
624                if x_l[i] > inner_x_l[i] + warm_tol {
625                    zl[i] = v0;
626                }
627                if x_u[i] < inner_x_u[i] - warm_tol {
628                    zu[i] = v0;
629                }
630            }
631            (zl, zu)
632        } else {
633            (vec![0.0; n], vec![0.0; n])
634        };
635
636        // Phase 2: detect redundant linear rows in the (possibly
637        // tightened) box. Non-linear rows are never dropped. The
638        // `row_kept_inner` mask was initialised above by Phase 0.
639        let mut n_dropped_rows: Index = 0;
640        if self.opts.redundant_constraint_removal {
641            let redundant_mask = find_redundant_rows(&linear_rows, &x_l, &x_u, 1e-9);
642            // redundant_mask aligns with `linear_rows`; map back to
643            // inner row indices.
644            let mut linear_iter = redundant_mask.iter();
645            for (i, lr) in linear_row_map.iter().enumerate() {
646                if lr.is_some() {
647                    let is_red = *linear_iter.next().unwrap_or(&false);
648                    if is_red {
649                        row_kept_inner[i] = false;
650                        n_dropped_rows += 1;
651                    }
652                }
653            }
654        }
655
656        // Phase 3: structural LICQ check on the kept equality rows.
657        let licq_verdict = if self.opts.licq_check {
658            let eq_tol: Number = 1e-12;
659            let mut eq_rows: Vec<EqRow> = Vec::new();
660            for (i, &kept) in row_kept_inner.iter().enumerate() {
661                if !kept {
662                    continue;
663                }
664                if (g_u_inner[i] - g_l_inner[i]).abs() > eq_tol {
665                    continue;
666                }
667                use std::collections::BTreeSet;
668                let mut cols: BTreeSet<Index> = BTreeSet::new();
669                for &(j, v) in &by_row[i] {
670                    if v != 0.0 {
671                        cols.insert(j);
672                    }
673                }
674                eq_rows.push(EqRow {
675                    cols: cols.into_iter().collect(),
676                });
677            }
678            Some(licq_check(&eq_rows, info_inner.n))
679        } else {
680            None
681        };
682
683        // Build outer row mapping.
684        let mut rows_kept: Vec<usize> = Vec::with_capacity(m_in);
685        let mut row_inner_to_outer = vec![usize::MAX; m_in];
686        for (i, &kept) in row_kept_inner.iter().enumerate() {
687            if kept {
688                row_inner_to_outer[i] = rows_kept.len();
689                rows_kept.push(i);
690            }
691        }
692        let m_out = rows_kept.len();
693
694        // Build outer Jacobian sparsity: keep entries whose row is
695        // kept, renumber rows.
696        let mut jac_kept_idx = Vec::new();
697        let mut jac_irow_outer = Vec::new();
698        let mut jac_jcol_outer = Vec::new();
699        for k in 0..nnz_in {
700            let i_inner = if one_based {
701                (jac_irow_inner[k] - 1) as usize
702            } else {
703                jac_irow_inner[k] as usize
704            };
705            if i_inner >= m_in {
706                continue;
707            }
708            if !row_kept_inner[i_inner] {
709                continue;
710            }
711            let outer = row_inner_to_outer[i_inner];
712            let outer_row_index = if one_based {
713                (outer as Index) + 1
714            } else {
715                outer as Index
716            };
717            jac_irow_outer.push(outer_row_index);
718            jac_jcol_outer.push(jac_jcol_inner[k]);
719            jac_kept_idx.push(k);
720        }
721        let nnz_out = jac_kept_idx.len();
722
723        // Reduced g_l, g_u in outer ordering.
724        let g_l: Vec<Number> = rows_kept.iter().map(|&i| g_l_inner[i]).collect();
725        let g_u: Vec<Number> = rows_kept.iter().map(|&i| g_u_inner[i]).collect();
726
727        let info_outer = NlpInfo {
728            n: info_inner.n,
729            m: m_out as Index,
730            nnz_jac_g: nnz_out as Index,
731            // Linear rows contribute zero to the Hessian, so dropping
732            // them does not change `nnz_h_lag`. We carry the inner
733            // sparsity through unchanged.
734            nnz_h_lag: info_inner.nnz_h_lag,
735            index_style: info_inner.index_style,
736        };
737
738        self.state = Some(PresolveState {
739            info_inner,
740            info_outer,
741            bounds: CachedBounds { x_l, x_u, g_l, g_u },
742            rows_kept,
743            jac_kept_idx,
744            jac_irow_outer,
745            jac_jcol_outer,
746            tighten_report,
747            fbbt_report,
748            n_dropped_rows,
749            licq_verdict,
750            z_l_warm,
751            z_u_warm,
752            scratch_g: vec![0.0; m_in],
753            scratch_jac: vec![0.0; nnz_in],
754            scratch_lambda: vec![0.0; m_in],
755            aux_diagnostics,
756            reduction_stack,
757        });
758        self.state.as_ref()
759    }
760}
761
762// Inside this impl every `.expect("inited")` is invariant-protected
763// by the preceding `ensure_init` (which is the only way state ever
764// becomes `Some`).
765#[allow(clippy::expect_used)]
766impl TNLP for PresolveTnlp {
767    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
768        let s = self.ensure_init()?;
769        Some(s.info_outer)
770    }
771
772    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
773        let Some(s) = self.ensure_init() else {
774            return false;
775        };
776        b.x_l.copy_from_slice(&s.bounds.x_l);
777        b.x_u.copy_from_slice(&s.bounds.x_u);
778        b.g_l.copy_from_slice(&s.bounds.g_l);
779        b.g_u.copy_from_slice(&s.bounds.g_u);
780        true
781    }
782
783    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
784        // n is unchanged by presolve; lambda warm-start is masked to
785        // kept rows only (caller already sized `sp.lambda` to m_out).
786        let Some(_) = self.ensure_init() else {
787            return false;
788        };
789        // For now, ask the inner TNLP for its starting point in full
790        // and project lambda down. Most users don't warm-start
791        // duals, so this hits the no-op path.
792        let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
793        let mut z_l_full = vec![0.0; sp.z_l.len()];
794        let mut z_u_full = vec![0.0; sp.z_u.len()];
795        let mut lambda_full = vec![0.0; m_in];
796        let ok = self.inner.borrow_mut().get_starting_point(StartingPoint {
797            init_x: sp.init_x,
798            x: sp.x,
799            init_z: sp.init_z,
800            z_l: &mut z_l_full,
801            z_u: &mut z_u_full,
802            init_lambda: sp.init_lambda,
803            lambda: &mut lambda_full,
804        });
805        if !ok {
806            return false;
807        }
808        sp.z_l.copy_from_slice(&z_l_full);
809        sp.z_u.copy_from_slice(&z_u_full);
810        let s = self.state.as_ref().expect("inited");
811        // Phase 4: overlay presolve hints onto any zero/unset
812        // entries. User-supplied warm-start values always win.
813        if sp.init_z && self.opts.warm_z_bounds {
814            for (i, &hint) in s.z_l_warm.iter().enumerate() {
815                if hint > 0.0 && sp.z_l[i] <= 0.0 {
816                    sp.z_l[i] = hint;
817                }
818            }
819            for (i, &hint) in s.z_u_warm.iter().enumerate() {
820                if hint > 0.0 && sp.z_u[i] <= 0.0 {
821                    sp.z_u[i] = hint;
822                }
823            }
824        }
825        for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
826            sp.lambda[outer] = lambda_full[i_inner];
827        }
828        true
829    }
830
831    fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
832        self.inner.borrow_mut().eval_f(x, new_x)
833    }
834
835    fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
836        self.inner.borrow_mut().eval_grad_f(x, new_x, grad_f)
837    }
838
839    fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
840        let Some(_) = self.ensure_init() else {
841            return false;
842        };
843        let s = self.state.as_mut().expect("inited");
844        if !self.inner.borrow_mut().eval_g(x, new_x, &mut s.scratch_g) {
845            return false;
846        }
847        for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
848            g[outer] = s.scratch_g[i_inner];
849        }
850        true
851    }
852
853    fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
854        let Some(_) = self.ensure_init() else {
855            return false;
856        };
857        match mode {
858            SparsityRequest::Structure { irow, jcol } => {
859                let s = self.state.as_ref().expect("inited");
860                irow.copy_from_slice(&s.jac_irow_outer);
861                jcol.copy_from_slice(&s.jac_jcol_outer);
862                true
863            }
864            SparsityRequest::Values { values } => {
865                let s = self.state.as_mut().expect("inited");
866                if !self.inner.borrow_mut().eval_jac_g(
867                    x,
868                    new_x,
869                    SparsityRequest::Values {
870                        values: &mut s.scratch_jac,
871                    },
872                ) {
873                    return false;
874                }
875                for (outer_k, &inner_k) in s.jac_kept_idx.iter().enumerate() {
876                    values[outer_k] = s.scratch_jac[inner_k];
877                }
878                true
879            }
880        }
881    }
882
883    fn eval_h(
884        &mut self,
885        x: Option<&[Number]>,
886        new_x: bool,
887        obj_factor: Number,
888        lambda: Option<&[Number]>,
889        new_lambda: bool,
890        mode: SparsityRequest<'_>,
891    ) -> bool {
892        let Some(_) = self.ensure_init() else {
893            return false;
894        };
895        // Hessian sparsity is untouched: linear rows (the only ones
896        // we drop) contribute zero. Forward `lambda` after expanding
897        // outer → inner with zeros at dropped rows.
898        let lambda_full_opt = if let Some(lam) = lambda {
899            let s = self.state.as_mut().expect("inited");
900            for v in s.scratch_lambda.iter_mut() {
901                *v = 0.0;
902            }
903            for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
904                s.scratch_lambda[i_inner] = lam[outer];
905            }
906            Some(&s.scratch_lambda[..])
907        } else {
908            None
909        };
910        // Re-borrow inner after dropping the state borrow.
911        let lam_ref: Option<&[Number]> = lambda_full_opt;
912        // SAFETY: `lam_ref` borrows from `self.state`'s scratch; the
913        // call to `inner.borrow_mut()` does not touch `self.state`.
914        self.inner
915            .borrow_mut()
916            .eval_h(x, new_x, obj_factor, lam_ref, new_lambda, mode)
917    }
918
919    fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
920        let Some(_) = self.ensure_init() else {
921            // Init failed earlier — best effort: just forward as-is.
922            self.inner
923                .borrow_mut()
924                .finalize_solution(sol, ip_data, ip_cq);
925            return;
926        };
927        // Reconstruct inner-sized g and lambda.
928        let (g_full, mut lambda_full, n_inner, m_inner, nnz_inner, one_based) = {
929            let s = self.state.as_mut().expect("inited");
930            // Recompute g at sol.x — the solver gave us reduced g.
931            self.inner
932                .borrow_mut()
933                .eval_g(sol.x, true, &mut s.scratch_g);
934            for v in s.scratch_lambda.iter_mut() {
935                *v = 0.0;
936            }
937            for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
938                s.scratch_lambda[i_inner] = sol.lambda[outer];
939            }
940            (
941                s.scratch_g.clone(),
942                s.scratch_lambda.clone(),
943                s.info_inner.n as usize,
944                s.info_inner.m as usize,
945                s.info_inner.nnz_jac_g as usize,
946                matches!(s.info_inner.index_style, IndexStyle::Fortran),
947            )
948        };
949
950        // Phase-0 (issue #53) multiplier recovery for dropped rows.
951        // Walk the reduction stack top-down; for each frame, compute
952        // the full-space ∇f and J at sol.x, then solve the k×k LU
953        // system to recover λ for the frame's dropped rows. Splice
954        // the result into `lambda_full` at the dropped indices.
955        let frames: Vec<reduction_frame::ReductionFrame> = {
956            let s = self.state.as_ref().expect("inited");
957            s.reduction_stack.iter_top_down().cloned().collect()
958        };
959        if !frames.is_empty() && m_inner > 0 {
960            let mut grad_f = vec![0.0; n_inner];
961            let ok_grad = self
962                .inner
963                .borrow_mut()
964                .eval_grad_f(sol.x, true, &mut grad_f);
965            // Sparsity + values for the full inner Jacobian.
966            let mut jac_irow_inner = vec![0 as Index; nnz_inner];
967            let mut jac_jcol_inner = vec![0 as Index; nnz_inner];
968            let ok_struct = if nnz_inner > 0 {
969                self.inner.borrow_mut().eval_jac_g(
970                    None,
971                    false,
972                    SparsityRequest::Structure {
973                        irow: &mut jac_irow_inner,
974                        jcol: &mut jac_jcol_inner,
975                    },
976                )
977            } else {
978                true
979            };
980            let mut jac_values = vec![0.0; nnz_inner];
981            let ok_vals = if nnz_inner > 0 {
982                self.inner.borrow_mut().eval_jac_g(
983                    Some(sol.x),
984                    false,
985                    SparsityRequest::Values {
986                        values: &mut jac_values,
987                    },
988                )
989            } else {
990                true
991            };
992            if ok_grad && ok_struct && ok_vals {
993                // Densify the Jacobian (only needed for the recovery LU).
994                let mut jac_dense = vec![0.0; m_inner * n_inner];
995                for k in 0..nnz_inner {
996                    let i = if one_based {
997                        (jac_irow_inner[k] as isize - 1) as usize
998                    } else {
999                        jac_irow_inner[k] as usize
1000                    };
1001                    let j = if one_based {
1002                        (jac_jcol_inner[k] as isize - 1) as usize
1003                    } else {
1004                        jac_jcol_inner[k] as usize
1005                    };
1006                    if i < m_inner && j < n_inner {
1007                        jac_dense[i * n_inner + j] = jac_values[k];
1008                    }
1009                }
1010                for frame in &frames {
1011                    if let Ok(lam_dropped) =
1012                        frame.recover_dropped_multipliers(&grad_f, &jac_dense, &lambda_full)
1013                    {
1014                        for (idx, &r) in frame.dropped_rows.iter().enumerate() {
1015                            lambda_full[r] = lam_dropped[idx];
1016                        }
1017                    }
1018                }
1019            }
1020        }
1021        self.inner.borrow_mut().finalize_solution(
1022            Solution {
1023                status: sol.status,
1024                x: sol.x,
1025                z_l: sol.z_l,
1026                z_u: sol.z_u,
1027                g: &g_full,
1028                lambda: &lambda_full,
1029                obj_value: sol.obj_value,
1030            },
1031            ip_data,
1032            ip_cq,
1033        );
1034    }
1035
1036    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
1037        let Some(_) = self.ensure_init() else {
1038            return false;
1039        };
1040        // Variable count is unchanged by presolve, so var metadata
1041        // flows through. Constraint metadata is per-inner-row; if we
1042        // dropped rows, subset the per-row vectors to kept rows.
1043        let mut inner_var = MetaData::default();
1044        let mut inner_con = MetaData::default();
1045        if !self
1046            .inner
1047            .borrow_mut()
1048            .get_var_con_metadata(&mut inner_var, &mut inner_con)
1049        {
1050            return false;
1051        }
1052        *var = inner_var;
1053        let s = self.state.as_ref().expect("inited");
1054        let m_in = s.info_inner.m as usize;
1055        *con = project_con_metadata(&inner_con, &s.rows_kept, m_in);
1056        true
1057    }
1058
1059    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
1060        let Some(_) = self.ensure_init() else {
1061            return false;
1062        };
1063        let s = self.state.as_ref().expect("inited");
1064        let m_in = s.info_inner.m as usize;
1065        // Allocate inner-sized g_scaling and forward.
1066        let mut inner_g = vec![1.0; m_in];
1067        let mut use_x = false;
1068        let mut use_g = false;
1069        let mut obj_scaling = 1.0;
1070        let inner_x_scaling_len = req.x_scaling.len();
1071        let mut inner_x = vec![1.0; inner_x_scaling_len];
1072        let ok = self
1073            .inner
1074            .borrow_mut()
1075            .get_scaling_parameters(ScalingRequest {
1076                obj_scaling: &mut obj_scaling,
1077                use_x_scaling: &mut use_x,
1078                x_scaling: &mut inner_x,
1079                use_g_scaling: &mut use_g,
1080                g_scaling: &mut inner_g,
1081            });
1082        if !ok {
1083            return false;
1084        }
1085        *req.obj_scaling = obj_scaling;
1086        *req.use_x_scaling = use_x;
1087        *req.use_g_scaling = use_g;
1088        req.x_scaling.copy_from_slice(&inner_x);
1089        for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
1090            req.g_scaling[outer] = inner_g[i_inner];
1091        }
1092        true
1093    }
1094
1095    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
1096        self.inner.borrow_mut().get_variables_linearity(types)
1097    }
1098
1099    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
1100        let Some(_) = self.ensure_init() else {
1101            return false;
1102        };
1103        let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
1104        let mut full = vec![Linearity::NonLinear; m_in];
1105        if !self.inner.borrow_mut().get_constraints_linearity(&mut full) {
1106            return false;
1107        }
1108        let s = self.state.as_ref().expect("inited");
1109        for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
1110            types[outer] = full[i_inner];
1111        }
1112        true
1113    }
1114
1115    fn get_number_of_nonlinear_variables(&mut self) -> Index {
1116        self.inner.borrow_mut().get_number_of_nonlinear_variables()
1117    }
1118
1119    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
1120        self.inner
1121            .borrow_mut()
1122            .get_list_of_nonlinear_variables(pos_nonlin_vars)
1123    }
1124
1125    fn intermediate_callback(
1126        &mut self,
1127        stats: IterStats,
1128        ip_data: &IpoptData,
1129        ip_cq: &IpoptCq,
1130    ) -> bool {
1131        self.inner
1132            .borrow_mut()
1133            .intermediate_callback(stats, ip_data, ip_cq)
1134    }
1135
1136    fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
1137        let Some(_) = self.ensure_init() else {
1138            self.inner.borrow_mut().finalize_metadata(var, con);
1139            return;
1140        };
1141        let s = self.state.as_ref().expect("inited");
1142        let m_in = s.info_inner.m as usize;
1143        let con_full = expand_con_metadata(con, &s.rows_kept, m_in);
1144        self.inner.borrow_mut().finalize_metadata(var, &con_full);
1145    }
1146}
1147
1148/// Subset every per-row vector of `inner` to the rows in
1149/// `rows_kept`. Per-row is identified by length == `m_in`; other
1150/// vectors are passed through unchanged.
1151fn project_con_metadata(inner: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
1152    let mut out = MetaData::default();
1153    for (k, v) in &inner.strings {
1154        out.strings.insert(
1155            k.clone(),
1156            if v.len() == m_in {
1157                rows_kept.iter().map(|&i| v[i].clone()).collect()
1158            } else {
1159                v.clone()
1160            },
1161        );
1162    }
1163    for (k, v) in &inner.integers {
1164        out.integers.insert(
1165            k.clone(),
1166            if v.len() == m_in {
1167                rows_kept.iter().map(|&i| v[i]).collect()
1168            } else {
1169                v.clone()
1170            },
1171        );
1172    }
1173    for (k, v) in &inner.numerics {
1174        out.numerics.insert(
1175            k.clone(),
1176            if v.len() == m_in {
1177                rows_kept.iter().map(|&i| v[i]).collect()
1178            } else {
1179                v.clone()
1180            },
1181        );
1182    }
1183    out
1184}
1185
1186/// Expand every per-(outer-row) vector back to `m_in` rows by
1187/// inserting empty / 0 / 0.0 defaults at dropped rows.
1188fn expand_con_metadata(outer: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
1189    let m_out = rows_kept.len();
1190    let mut full = MetaData::default();
1191    for (k, v) in &outer.strings {
1192        let mut buf: Vec<String> = vec![String::new(); m_in];
1193        if v.len() == m_out {
1194            for (outer_i, val) in v.iter().enumerate() {
1195                buf[rows_kept[outer_i]] = val.clone();
1196            }
1197            full.strings.insert(k.clone(), buf);
1198        } else {
1199            full.strings.insert(k.clone(), v.clone());
1200        }
1201    }
1202    for (k, v) in &outer.integers {
1203        let mut buf: Vec<Index> = vec![0; m_in];
1204        if v.len() == m_out {
1205            for (outer_i, &val) in v.iter().enumerate() {
1206                buf[rows_kept[outer_i]] = val;
1207            }
1208            full.integers.insert(k.clone(), buf);
1209        } else {
1210            full.integers.insert(k.clone(), v.clone());
1211        }
1212    }
1213    for (k, v) in &outer.numerics {
1214        let mut buf: Vec<Number> = vec![0.0; m_in];
1215        if v.len() == m_out {
1216            for (outer_i, &val) in v.iter().enumerate() {
1217                buf[rows_kept[outer_i]] = val;
1218            }
1219            full.numerics.insert(k.clone(), buf);
1220        } else {
1221            full.numerics.insert(k.clone(), v.clone());
1222        }
1223    }
1224    full
1225}
1226
1227/// Re-export for callers that already imported
1228/// `pounce_presolve::register_options` directly.
1229pub fn register(reg: &RegisteredOptions) -> Result<(), SolverException> {
1230    register_options(reg)
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236
1237    struct Probe;
1238    impl TNLP for Probe {
1239        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1240            Some(NlpInfo {
1241                n: 1,
1242                m: 0,
1243                nnz_jac_g: 0,
1244                nnz_h_lag: 1,
1245                index_style: IndexStyle::C,
1246            })
1247        }
1248        fn get_bounds_info(&mut self, _b: BoundsInfo<'_>) -> bool {
1249            true
1250        }
1251        fn get_starting_point(&mut self, _sp: StartingPoint<'_>) -> bool {
1252            true
1253        }
1254        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
1255            Some(0.0)
1256        }
1257        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
1258            true
1259        }
1260        fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
1261            true
1262        }
1263        fn eval_jac_g(
1264            &mut self,
1265            _x: Option<&[Number]>,
1266            _new_x: bool,
1267            _mode: SparsityRequest<'_>,
1268        ) -> bool {
1269            true
1270        }
1271        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
1272    }
1273
1274    #[test]
1275    fn disabled_returns_inner_unchanged() {
1276        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
1277        let opts = PresolveOptions {
1278            enabled: false,
1279            ..PresolveOptions::defaults()
1280        };
1281        let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
1282        assert!(Rc::ptr_eq(&inner, &wrapped));
1283    }
1284
1285    #[test]
1286    fn enabled_wraps_and_forwards() {
1287        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
1288        let opts = PresolveOptions {
1289            enabled: true,
1290            ..PresolveOptions::defaults()
1291        };
1292        let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
1293        assert!(!Rc::ptr_eq(&inner, &wrapped));
1294        let info = wrapped.borrow_mut().get_nlp_info().unwrap();
1295        assert_eq!(info.n, 1);
1296        assert_eq!(info.m, 0);
1297    }
1298
1299    #[test]
1300    fn register_options_roundtrip() {
1301        let reg = RegisteredOptions::default();
1302        register_options(&reg).unwrap();
1303        let opt = reg.get_option("presolve").expect("presolve registered");
1304        assert_eq!(opt.name, "presolve");
1305    }
1306
1307    #[test]
1308    fn auxiliary_phase0_noop_when_disabled() {
1309        // Master switch off → Phase 0 returns zero diagnostics and
1310        // does not perturb the inner problem's dimensions.
1311        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
1312        let opts = PresolveOptions {
1313            enabled: true,
1314            auxiliary: false,
1315            ..PresolveOptions::defaults()
1316        };
1317        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1318        let info = wrapped.get_nlp_info().expect("init ok");
1319        assert_eq!(info.n, 1);
1320        assert_eq!(info.m, 0);
1321        let diag = wrapped.auxiliary_diagnostics();
1322        assert_eq!(diag.blocks_eliminated, 0);
1323        assert_eq!(diag.vars_eliminated, 0);
1324        assert_eq!(diag.rows_eliminated, 0);
1325    }
1326
1327    #[test]
1328    fn auxiliary_phase0_noop_when_enabled_no_algos_yet() {
1329        // For a 1-var, 0-row TNLP there are no candidate blocks; the
1330        // orchestrator skips its work regardless of master-switch state.
1331        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
1332        let opts = PresolveOptions {
1333            enabled: true,
1334            auxiliary: true,
1335            auxiliary_coupling: AuxiliaryCouplingPolicy::Aggressive,
1336            ..PresolveOptions::defaults()
1337        };
1338        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1339        let info = wrapped.get_nlp_info().expect("init ok");
1340        assert_eq!(info.n, 1);
1341        assert_eq!(info.m, 0);
1342        let diag = wrapped.auxiliary_diagnostics();
1343        assert_eq!(diag.blocks_eliminated, 0);
1344        assert!(diag.rejection_reasons.is_empty());
1345    }
1346
1347    /// 2-variable, 2-equality TNLP that PR 8's orchestrator should
1348    /// reduce: `x + y = 3, x - y = 1` → unique solution `(2, 1)`.
1349    /// Zero objective gradient → PureEquality, eligible under Safe.
1350    struct TwoVarSquareEq;
1351    impl TNLP for TwoVarSquareEq {
1352        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
1353            Some(NlpInfo {
1354                n: 2,
1355                m: 2,
1356                nnz_jac_g: 4,
1357                nnz_h_lag: 0,
1358                index_style: IndexStyle::C,
1359            })
1360        }
1361        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
1362            for v in b.x_l.iter_mut() {
1363                *v = -1e19;
1364            }
1365            for v in b.x_u.iter_mut() {
1366                *v = 1e19;
1367            }
1368            b.g_l[0] = 3.0;
1369            b.g_u[0] = 3.0;
1370            b.g_l[1] = 1.0;
1371            b.g_u[1] = 1.0;
1372            true
1373        }
1374        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
1375            if sp.init_x {
1376                sp.x[0] = 0.0;
1377                sp.x[1] = 0.0;
1378            }
1379            true
1380        }
1381        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
1382            Some(0.0)
1383        }
1384        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1385            for v in g.iter_mut() {
1386                *v = 0.0;
1387            }
1388            true
1389        }
1390        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
1391            g[0] = x[0] + x[1];
1392            g[1] = x[0] - x[1];
1393            true
1394        }
1395        fn eval_jac_g(
1396            &mut self,
1397            _x: Option<&[Number]>,
1398            _new_x: bool,
1399            mode: SparsityRequest<'_>,
1400        ) -> bool {
1401            match mode {
1402                SparsityRequest::Structure { irow, jcol } => {
1403                    irow.copy_from_slice(&[0, 0, 1, 1]);
1404                    jcol.copy_from_slice(&[0, 1, 0, 1]);
1405                }
1406                SparsityRequest::Values { values } => {
1407                    values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
1408                }
1409            }
1410            true
1411        }
1412        fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
1413            types[0] = Linearity::Linear;
1414            types[1] = Linearity::Linear;
1415            true
1416        }
1417        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
1418    }
1419
1420    #[test]
1421    fn phase0_via_tnlp_eliminates_square_block() {
1422        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
1423        let opts = PresolveOptions {
1424            enabled: true,
1425            auxiliary: true,
1426            auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
1427            ..PresolveOptions::defaults()
1428        };
1429        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1430        let info = wrapped.get_nlp_info().expect("init ok");
1431        // Variable count unchanged (clamp, not reduce).
1432        assert_eq!(info.n, 2);
1433        // Both equality rows dropped.
1434        assert_eq!(info.m, 0);
1435
1436        let diag = wrapped.auxiliary_diagnostics();
1437        assert_eq!(diag.blocks_eliminated, 1);
1438        assert_eq!(diag.vars_eliminated, 2);
1439        assert_eq!(diag.rows_eliminated, 2);
1440
1441        let bounds = wrapped.cached_bounds().expect("inited");
1442        assert!((bounds.x_l[0] - 2.0).abs() < 1e-12);
1443        assert!((bounds.x_u[0] - 2.0).abs() < 1e-12);
1444        assert!((bounds.x_l[1] - 1.0).abs() < 1e-12);
1445        assert!((bounds.x_u[1] - 1.0).abs() < 1e-12);
1446    }
1447
1448    #[test]
1449    fn phase0_via_tnlp_disabled_is_pass_through() {
1450        // Same inner TNLP, but presolve_auxiliary=no. Orchestrator
1451        // is byte-identical to today; both rows remain.
1452        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
1453        let opts = PresolveOptions {
1454            enabled: true,
1455            auxiliary: false,
1456            ..PresolveOptions::defaults()
1457        };
1458        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1459        let info = wrapped.get_nlp_info().expect("init ok");
1460        assert_eq!(info.n, 2);
1461        assert_eq!(info.m, 2);
1462        let diag = wrapped.auxiliary_diagnostics();
1463        assert_eq!(diag.blocks_eliminated, 0);
1464    }
1465
1466    /// Smoke test: turning on `presolve_auxiliary_diagnostics` still
1467    /// produces a correct elimination. We can't easily capture
1468    /// stderr from inside a #[test], but we can verify the option
1469    /// path doesn't break the orchestrator.
1470    #[test]
1471    fn phase0_via_tnlp_diagnostics_flag_does_not_break_solve() {
1472        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
1473        let opts = PresolveOptions {
1474            enabled: true,
1475            auxiliary: true,
1476            auxiliary_diagnostics: true,
1477            ..PresolveOptions::defaults()
1478        };
1479        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1480        let info = wrapped.get_nlp_info().expect("init ok");
1481        assert_eq!(info.m, 0);
1482        let diag = wrapped.auxiliary_diagnostics();
1483        assert_eq!(diag.blocks_eliminated, 1);
1484    }
1485
1486    /// Regression for the #60 PR review blocker. The original
1487    /// `TwoVarSquareEq` TNLP, when wrapped with BOTH
1488    /// `presolve_auxiliary=yes` AND `presolve_bound_tightening=yes`
1489    /// (the new defaults), used to:
1490    ///   (1) let aux clamp x_l[0..2] = x_u[0..2] = solved values;
1491    ///   (2) let `tighten_bounds` re-propagate the (dropped)
1492    ///       equality rows over the clamped bounds, derive a
1493    ///       contradiction, and set `tighten_report.infeasible`;
1494    ///   (3) hand `x_l > x_u` to the IPM (because `infeasible` was
1495    ///       never inspected), crashing it with
1496    ///       `Invalid Problem Definition`.
1497    /// The fix: build `linear_rows` AFTER Phase 0, filtered by
1498    /// `row_kept_inner`, so dropped rows don't propagate. With this
1499    /// test we just confirm the wrapper init still succeeds and the
1500    /// final bounds remain self-consistent.
1501    #[test]
1502    fn phase0_via_tnlp_no_infeasible_with_default_bound_tightening() {
1503        let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
1504        let opts = PresolveOptions {
1505            enabled: true,
1506            auxiliary: true,
1507            // Both phases on — this is the combination that crashed
1508            // on `gaslib11_steady.nl` before the fix.
1509            bound_tightening: true,
1510            ..PresolveOptions::defaults()
1511        };
1512        let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
1513        let info = wrapped.get_nlp_info().expect("init ok");
1514        assert_eq!(info.m, 0); // aux dropped both rows
1515        let bounds = wrapped.cached_bounds().expect("inited");
1516        // x_l ≤ x_u must hold for every variable.
1517        for i in 0..(info.n as usize) {
1518            assert!(
1519                bounds.x_l[i] <= bounds.x_u[i] + 1e-12,
1520                "x_l[{i}] = {} > x_u[{i}] = {}",
1521                bounds.x_l[i],
1522                bounds.x_u[i]
1523            );
1524        }
1525        // tighten_report must NOT have flagged infeasibility.
1526        let rpt = wrapped.tighten_report();
1527        assert!(!rpt.infeasible, "Phase 1 falsely flagged infeasibility");
1528    }
1529}