#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::cell::RefCell;
use std::rc::Rc;
use pounce_common::exception::SolverException;
use pounce_common::options_list::OptionsList;
use pounce_common::reg_options::RegisteredOptions;
use pounce_common::tolerance::is_negligible;
use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
use pounce_nlp::expression_provider::ExpressionProvider;
use pounce_nlp::tnlp::{
BoundsInfo, IndexStyle, InfeasibilityProof, IpoptCq, IpoptData, IterStats, Linearity, MetaData,
NlpInfo, ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
};
pub mod auxiliary;
pub mod block_solve;
pub mod bound_tighten;
pub mod btf;
pub mod components;
pub mod coupling;
pub mod diagnostics;
pub mod dulmage_mendelsohn;
pub mod fbbt;
pub mod incidence;
pub mod inequality_projection;
pub mod licq;
pub mod linear_eq_elim;
pub mod linear_eq_plan;
pub mod matching;
pub mod options;
pub mod reduction_frame;
pub mod redundant;
pub mod trivial_elim;
pub use block_solve::{
BlockEquations, BlockSolveError, BlockSolveOptions, BlockSolveOutcome, BlockSolver,
DampedNewtonSolver,
};
pub use bound_tighten::{INF_BOUND, LinearRow, TightenReport, tighten_bounds};
pub use btf::{BlockTriangularBlock, BlockTriangularForm};
pub use components::{SquareComponent, SquareComponents};
pub use coupling::{AuxiliaryCouplingClass, classify_block, objective_gradient_support};
pub use diagnostics::{AuxiliaryPreprocessingDiagnostics, AuxiliaryRejectionReason};
pub use dulmage_mendelsohn::{DMPart, DulmageMendelsohnPartition};
pub use incidence::{EqualityIncidence, InequalityIncidence, ProbeView};
pub use licq::{EqRow, LicqVerdict, licq_check};
pub use linear_eq_elim::{FullSolution, LinearEqElimTnlp, recover_dropped_multipliers};
pub use linear_eq_plan::{
ElimStep, EliminationPlan, LinearEqElimReport, PlanConfig, PlanInput, VarRecovery, build_plan,
};
pub use options::{AuxiliaryCouplingPolicy, LicqAction, PresolveOptions, register_options};
pub use reduction_frame::{ReductionFrame, ReductionStack};
pub use redundant::find_redundant_rows;
#[derive(Debug)]
pub enum PresolveError {
OptionsError(SolverException),
}
impl std::fmt::Display for PresolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OptionsError(e) => write!(f, "presolve options error: {e}"),
}
}
}
impl std::error::Error for PresolveError {}
impl From<SolverException> for PresolveError {
fn from(e: SolverException) -> Self {
Self::OptionsError(e)
}
}
fn stack_linear_eq_elim(
wrapped: Rc<RefCell<dyn TNLP>>,
opts: PresolveOptions,
) -> Rc<RefCell<dyn TNLP>> {
if !opts.linear_eq_reduction {
return wrapped;
}
Rc::new(RefCell::new(LinearEqElimTnlp::new(wrapped, opts)))
}
pub fn wrap_with_presolve(
inner: Rc<RefCell<dyn TNLP>>,
opts: PresolveOptions,
) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
if !opts.enabled || inner.borrow().is_presolve_wrapper() {
return Ok(inner);
}
let wrapped: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(PresolveTnlp::new(inner, opts)));
Ok(stack_linear_eq_elim(wrapped, opts))
}
pub fn wrap_with_presolve_provider(
inner: Rc<RefCell<dyn TNLP>>,
expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
opts: PresolveOptions,
) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
if !opts.enabled || inner.borrow().is_presolve_wrapper() {
return Ok(inner);
}
let wrapped: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(
PresolveTnlp::with_expression_provider(inner, expr_provider, opts),
));
Ok(stack_linear_eq_elim(wrapped, opts))
}
pub fn wrap_from_options(
inner: Rc<RefCell<dyn TNLP>>,
options: &OptionsList,
) -> Result<Rc<RefCell<dyn TNLP>>, PresolveError> {
let opts = PresolveOptions::from_options_list(options)?;
wrap_with_presolve(inner, opts)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WitnessRule {
#[default]
SolverAcceptance,
DeclaredRowRelative,
}
#[allow(clippy::too_many_arguments)]
fn witness_refutes_infeasibility(
inner: &Rc<RefCell<dyn TNLP>>,
n: usize,
m_in: usize,
x_l: &[Number],
x_u: &[Number],
g_l: &[Number],
g_u: &[Number],
tol: Number,
rule: WitnessRule,
) -> bool {
if m_in == 0 || n == 0 {
return false;
}
let clamp = |v: Number, fallback: Number| -> Number {
if v.is_finite() && v.abs() < crate::bound_tighten::INF_BOUND {
v
} else {
fallback
}
};
let lo: Vec<Number> = (0..n).map(|j| clamp(x_l[j], -1.0)).collect();
let hi: Vec<Number> = (0..n).map(|j| clamp(x_u[j], 1.0)).collect();
let mid: Vec<Number> = (0..n).map(|j| 0.5 * (lo[j] + hi[j])).collect();
let mut x0 = vec![0.0; n];
let mut z_l = vec![0.0; n];
let mut z_u = vec![0.0; n];
let mut lam = vec![0.0; m_in];
let have_x0 = inner.borrow_mut().get_starting_point(StartingPoint {
init_x: true,
x: &mut x0,
init_z: false,
z_l: &mut z_l,
z_u: &mut z_u,
init_lambda: false,
lambda: &mut lam,
});
let mut g = vec![0.0; m_in];
let mut candidates: Vec<&Vec<Number>> = Vec::with_capacity(4);
if have_x0 && x0.iter().all(|v| v.is_finite()) {
candidates.push(&x0);
}
candidates.extend([&mid, &lo, &hi]);
for x in candidates {
if !inner.borrow_mut().eval_g(x, true, &mut g) {
continue;
}
let feasible = (0..m_in).all(|i| {
let v = g[i];
if !v.is_finite() {
return false;
}
let lo_present = |b: Number| b.is_finite() && b > -crate::bound_tighten::INF_BOUND;
let up_present = |b: Number| b.is_finite() && b < crate::bound_tighten::INF_BOUND;
let declared = (if lo_present(g_l[i]) {
g_l[i].abs()
} else {
0.0
})
.max(if up_present(g_u[i]) {
g_u[i].abs()
} else {
0.0
});
let scale = v.abs().max(declared);
let lo_viol = if lo_present(g_l[i]) { g_l[i] - v } else { 0.0 };
let hi_viol = if up_present(g_u[i]) { v - g_u[i] } else { 0.0 };
let viol = lo_viol.max(hi_viol).max(0.0);
match rule {
WitnessRule::SolverAcceptance => is_negligible(viol, scale, tol),
WitnessRule::DeclaredRowRelative if declared > 0.0 => {
viol.is_finite() && viol <= tol * declared
}
WitnessRule::DeclaredRowRelative => is_negligible(viol, scale, tol),
}
});
if feasible {
return true;
}
}
false
}
fn certificate_data_is_admissible(
x_l: &[Number],
x_u: &[Number],
g_l: &[Number],
g_u: &[Number],
) -> bool {
let sane = |v: &Number| {
let v = *v;
!v.is_nan() && (v.is_infinite() || v.abs() < crate::bound_tighten::INF_BOUND)
};
x_l.iter().all(sane) && x_u.iter().all(sane) && g_l.iter().all(sane) && g_u.iter().all(sane)
}
fn certify_margin(tol: Number) -> Number {
tol.max(1e-12)
}
fn crossing_is_certifiable(x_l: &[Number], x_u: &[Number], tol: Number) -> bool {
x_l.iter().zip(x_u).any(|(&l, &u)| {
lower_bound_present(l)
&& upper_bound_present(u)
&& l > u
&& !is_negligible(l - u, l.abs().max(u.abs()), tol)
})
}
fn row_margin_for(g_l: Number, g_u: Number, tol: Number) -> Number {
let lo_mag = if lower_bound_present(g_l) {
g_l.abs()
} else {
0.0
};
let hi_mag = if upper_bound_present(g_u) {
g_u.abs()
} else {
0.0
};
tol * lo_mag.max(hi_mag).max(1.0)
}
#[allow(clippy::too_many_arguments)]
fn fbbt_infeasibility_survives_margin(
provider: &dyn ExpressionProvider,
n: usize,
m_in: usize,
x_l: &[Number],
x_u: &[Number],
g_l: &[Number],
g_u: &[Number],
row_kept: &[bool],
cfg: &crate::fbbt::FbbtConfig,
tol: Number,
) -> bool {
let row_margin = |i: usize| -> Number { row_margin_for(g_l[i], g_u[i], tol) };
let g_l_relaxed: Vec<Number> = g_l
.iter()
.enumerate()
.map(|(i, &v)| {
if v <= -crate::bound_tighten::INF_BOUND {
v
} else {
v - row_margin(i)
}
})
.collect();
let g_u_relaxed: Vec<Number> = g_u
.iter()
.enumerate()
.map(|(i, &v)| {
if v >= crate::bound_tighten::INF_BOUND {
v
} else {
v + row_margin(i)
}
})
.collect();
if !certificate_data_is_admissible(x_l, x_u, g_l, g_u) {
return false;
}
let mut probe_x_l = x_l.to_vec();
let mut probe_x_u = x_u.to_vec();
let report = crate::fbbt::run_fbbt(
provider,
n,
m_in,
&mut probe_x_l,
&mut probe_x_u,
&g_l_relaxed,
&g_u_relaxed,
Some(row_kept),
cfg,
);
report.infeasibility_witness.is_some()
}
pub struct CachedBounds {
pub x_l: Vec<Number>,
pub x_u: Vec<Number>,
pub g_l: Vec<Number>,
pub g_u: Vec<Number>,
}
pub struct PresolveTnlp {
inner: Rc<RefCell<dyn TNLP>>,
expr_provider: Option<Rc<RefCell<dyn ExpressionProvider>>>,
opts: PresolveOptions,
witness_rule: WitnessRule,
state: Option<PresolveState>,
finalized_full_solution: Option<(Vec<Number>, Vec<Number>)>,
}
struct PresolveState {
info_inner: NlpInfo,
info_outer: NlpInfo,
bounds: CachedBounds,
rows_kept: Vec<usize>,
jac_kept_idx: Vec<usize>,
jac_irow_outer: Vec<Index>,
jac_jcol_outer: Vec<Index>,
tighten_report: TightenReport,
certified_infeasible: Option<InfeasibilityProof>,
fbbt_report: Option<crate::fbbt::FbbtReport>,
n_dropped_rows: Index,
licq_verdict: Option<LicqVerdict>,
z_l_warm: Vec<Number>,
z_u_warm: Vec<Number>,
scratch_g: Vec<Number>,
scratch_jac: Vec<Number>,
scratch_lambda: Vec<Number>,
aux_diagnostics: AuxiliaryPreprocessingDiagnostics,
#[allow(dead_code)]
reduction_stack: ReductionStack,
}
impl PresolveTnlp {
pub fn new(inner: Rc<RefCell<dyn TNLP>>, opts: PresolveOptions) -> Self {
Self {
inner,
expr_provider: None,
opts,
witness_rule: WitnessRule::default(),
state: None,
finalized_full_solution: None,
}
}
pub fn probing_without_a_solve(mut self) -> Self {
self.witness_rule = WitnessRule::DeclaredRowRelative;
self
}
pub fn with_expression_provider(
inner: Rc<RefCell<dyn TNLP>>,
expr_provider: Rc<RefCell<dyn ExpressionProvider>>,
opts: PresolveOptions,
) -> Self {
Self {
inner,
expr_provider: Some(expr_provider),
opts,
witness_rule: WitnessRule::default(),
state: None,
finalized_full_solution: None,
}
}
pub fn fbbt_report(&self) -> Option<crate::fbbt::FbbtReport> {
self.state.as_ref().and_then(|s| s.fbbt_report.clone())
}
pub fn tighten_report(&self) -> TightenReport {
self.state
.as_ref()
.map(|s| s.tighten_report.clone())
.unwrap_or_default()
}
pub fn certified_infeasible(&self) -> Option<InfeasibilityProof> {
self.state.as_ref().and_then(|s| s.certified_infeasible)
}
pub fn n_dropped_rows(&self) -> Index {
self.state.as_ref().map(|s| s.n_dropped_rows).unwrap_or(0)
}
pub fn finalized_full_solution(&self) -> Option<(Vec<Number>, Vec<Number>)> {
self.finalized_full_solution.clone()
}
pub fn cached_bounds(&self) -> Option<&CachedBounds> {
self.state.as_ref().map(|s| &s.bounds)
}
pub fn licq_verdict(&self) -> Option<&LicqVerdict> {
self.state.as_ref().and_then(|s| s.licq_verdict.as_ref())
}
pub fn z_warm_starts(&self) -> Option<(&[Number], &[Number])> {
self.state
.as_ref()
.map(|s| (&s.z_l_warm[..], &s.z_u_warm[..]))
}
pub fn auxiliary_diagnostics(&self) -> AuxiliaryPreprocessingDiagnostics {
self.state
.as_ref()
.map(|s| s.aux_diagnostics.clone())
.unwrap_or_default()
}
fn ensure_init(&mut self) -> Option<&PresolveState> {
if self.state.is_some() {
return self.state.as_ref();
}
let info_inner = self.inner.borrow_mut().get_nlp_info()?;
let n = info_inner.n as usize;
let m_in = info_inner.m as usize;
let nnz_in = info_inner.nnz_jac_g as usize;
let mut x_l = vec![0.0; n];
let mut x_u = vec![0.0; n];
let mut g_l_inner = vec![0.0; m_in];
let mut g_u_inner = vec![0.0; m_in];
{
let mut inner = self.inner.borrow_mut();
if !inner.get_bounds_info(BoundsInfo {
x_l: &mut x_l,
x_u: &mut x_u,
g_l: &mut g_l_inner,
g_u: &mut g_u_inner,
}) {
return None;
}
}
let mut jac_irow_inner = vec![0 as Index; nnz_in];
let mut jac_jcol_inner = vec![0 as Index; nnz_in];
if nnz_in > 0 {
let mut inner = self.inner.borrow_mut();
if !inner.eval_jac_g(
None,
false,
SparsityRequest::Structure {
irow: &mut jac_irow_inner,
jcol: &mut jac_jcol_inner,
},
) {
return None;
}
}
let mut linearity = vec![Linearity::NonLinear; m_in];
let have_linearity = if m_in > 0 {
self.inner
.borrow_mut()
.get_constraints_linearity(&mut linearity)
} else {
true
};
let mut var_linearity = vec![Linearity::NonLinear; n];
let have_var_linearity = {
let mut inner = self.inner.borrow_mut();
inner.get_objective_variables_linearity(&mut var_linearity)
|| inner.get_variables_linearity(&mut var_linearity)
};
let mut x_probe = vec![0.0; n];
let mut z_l_probe = vec![0.0; n];
let mut z_u_probe = vec![0.0; n];
let mut lambda_probe = vec![0.0; m_in];
let started = self.inner.borrow_mut().get_starting_point(StartingPoint {
init_x: true,
x: &mut x_probe,
init_z: false,
z_l: &mut z_l_probe,
z_u: &mut z_u_probe,
init_lambda: false,
lambda: &mut lambda_probe,
});
if !started {
return None;
}
let mut jac_values_inner = vec![0.0; nnz_in];
if nnz_in > 0 {
let ok = self.inner.borrow_mut().eval_jac_g(
Some(&x_probe),
true,
SparsityRequest::Values {
values: &mut jac_values_inner,
},
);
if !ok {
return None;
}
}
let one_based = matches!(info_inner.index_style, IndexStyle::Fortran);
let mut by_row: Vec<Vec<(Index, Number)>> = vec![Vec::new(); m_in];
for k in 0..nnz_in {
let i = if one_based {
(jac_irow_inner[k] - 1) as usize
} else {
jac_irow_inner[k] as usize
};
let j = if one_based {
jac_jcol_inner[k] - 1
} else {
jac_jcol_inner[k]
};
if i < m_in && (j as usize) < n {
by_row[i].push((j, jac_values_inner[k]));
}
}
let linear_row_map: Vec<Option<LinearRow>> = (0..m_in)
.map(|i| {
if have_linearity && matches!(linearity[i], Linearity::Linear) {
Some(LinearRow {
entries: by_row[i].clone(),
lo: g_l_inner[i],
hi: g_u_inner[i],
})
} else {
None
}
})
.collect();
let inner_x_l = x_l.clone();
let inner_x_u = x_u.clone();
let mut row_kept_inner: Vec<bool> = vec![true; m_in];
let mut reduction_stack = ReductionStack::default();
let aux_diagnostics = if self.opts.auxiliary && m_in > 0 {
let mut g_at_probe = vec![0.0; m_in];
let g_ok = self
.inner
.borrow_mut()
.eval_g(&x_probe, true, &mut g_at_probe);
if !g_ok {
return None;
}
let mut grad_f_probe = vec![0.0; n];
let grad_ok = self
.inner
.borrow_mut()
.eval_grad_f(&x_probe, false, &mut grad_f_probe);
if !grad_ok {
return None;
}
let linearity_for_phase0: Vec<Linearity> = if have_linearity {
linearity.clone()
} else {
vec![Linearity::NonLinear; m_in]
};
let probe_view = auxiliary::Phase0Probe {
n_vars: n,
n_rows: m_in,
jac_irow: &jac_irow_inner,
jac_jcol: &jac_jcol_inner,
jac_values: &jac_values_inner,
g_l: &g_l_inner,
g_u: &g_u_inner,
g_at_probe: &g_at_probe,
linearity: &linearity_for_phase0,
one_based,
eq_tol: 1e-12,
x_probe: &x_probe,
grad_f: &grad_f_probe,
var_linearity: if have_var_linearity {
Some(&var_linearity)
} else {
None
},
x_l: &x_l,
x_u: &x_u,
};
struct TnlpCallbackAdapter {
inner: Rc<RefCell<dyn TNLP>>,
}
impl auxiliary::Phase0TnlpCallback for TnlpCallbackAdapter {
fn eval_g_full(&mut self, x: &[Number], g: &mut [Number]) -> bool {
self.inner.borrow_mut().eval_g(x, true, g)
}
fn eval_jac_g_values(&mut self, x: &[Number], values: &mut [Number]) -> bool {
self.inner.borrow_mut().eval_jac_g(
Some(x),
true,
SparsityRequest::Values { values },
)
}
}
let mut adapter = TnlpCallbackAdapter {
inner: Rc::clone(&self.inner),
};
let mut large_solver = block_solve::RelaxedNewtonSolver;
let plan = auxiliary::run_auxiliary_phase0(
&self.opts,
&probe_view,
Some(&mut adapter),
Some(&mut large_solver),
);
if let Some(frame) = plan.frame {
for (k, &i) in frame.fixed_vars.iter().enumerate() {
x_l[i] = frame.fixed_values[k];
x_u[i] = frame.fixed_values[k];
}
for &r in &frame.dropped_rows {
row_kept_inner[r] = false;
}
reduction_stack.push(frame);
}
if self.opts.auxiliary_diagnostics {
tracing::info!(target: "pounce::presolve", "{}", plan.diagnostics);
}
plan.diagnostics
} else {
AuxiliaryPreprocessingDiagnostics::default()
};
let mut linear_rows: Vec<LinearRow> = linear_row_map
.iter()
.enumerate()
.filter_map(|(i, r)| if row_kept_inner[i] { r.clone() } else { None })
.collect();
let mut tighten_report = TightenReport::default();
let mut certified_infeasible: Option<InfeasibilityProof> = None;
if self.opts.bound_tightening && !linear_rows.is_empty() {
tighten_report = tighten_bounds(
&linear_rows,
&mut x_l,
&mut x_u,
self.opts.max_passes,
1e-12,
);
}
if tighten_report.infeasible && !reduction_stack.is_empty() {
tracing::warn!(
target: "pounce::presolve",
"auxiliary-equality elimination produced bounds inconsistent \
with kept linear rows; rolling back the elimination for this solve."
);
x_l.copy_from_slice(&inner_x_l);
x_u.copy_from_slice(&inner_x_u);
for kept in row_kept_inner.iter_mut() {
*kept = true;
}
reduction_stack = ReductionStack::default();
let full_linear_rows: Vec<LinearRow> =
linear_row_map.iter().filter_map(|r| r.clone()).collect();
tighten_report = TightenReport::default();
if self.opts.bound_tightening && !full_linear_rows.is_empty() {
tighten_report = tighten_bounds(
&full_linear_rows,
&mut x_l,
&mut x_u,
self.opts.max_passes,
1e-12,
);
}
linear_rows = full_linear_rows;
}
if tighten_report.infeasible {
let crossing = (0..n).map(|j| x_l[j] - x_u[j]).fold(0.0_f64, f64::max);
let robust = crossing_is_certifiable(&x_l, &x_u, certify_margin(self.opts.certify_tol));
x_l.copy_from_slice(&inner_x_l);
x_u.copy_from_slice(&inner_x_u);
if robust {
certified_infeasible = Some(InfeasibilityProof::BoundPropagation);
}
tracing::warn!(
target: "pounce::presolve",
crossing,
certified = robust,
"Phase 1 bound tightening found the feasible region empty; its \
crossed bounds are being discarded."
);
}
let collapse_tol = certify_margin(self.opts.certify_tol);
for j in 0..n {
if x_l[j] > x_u[j]
&& is_negligible(
x_l[j] - x_u[j],
x_l[j].abs().max(x_u[j].abs()),
collapse_tol,
)
{
let mid = (0.5 * (x_l[j] + x_u[j]))
.max(inner_x_l[j])
.min(inner_x_u[j]);
x_l[j] = mid;
x_u[j] = mid;
}
}
let mut fbbt_report: Option<crate::fbbt::FbbtReport> = None;
if self.opts.fbbt && m_in > 0 {
if let Some(provider) = self.expr_provider.as_ref() {
let cfg = crate::fbbt::FbbtConfig {
tol: self.opts.fbbt_tol,
max_iter: self.opts.fbbt_max_iter.max(1) as usize,
max_constraints: self.opts.fbbt_max_constraints.max(0) as usize,
};
let fbbt_x_l_pre = x_l.clone();
let fbbt_x_u_pre = x_u.clone();
let provider_borrow = provider.borrow();
let mut report = crate::fbbt::run_fbbt(
&*provider_borrow,
n,
m_in,
&mut x_l,
&mut x_u,
&g_l_inner,
&g_u_inner,
Some(&row_kept_inner),
&cfg,
);
drop(provider_borrow);
if report.infeasibility_witness.is_some() && !reduction_stack.is_empty() {
tracing::warn!(
target: "pounce::presolve",
witness = report.infeasibility_witness,
"FBBT reported a constraint infeasibility while auxiliary \
elimination was active; rolling back the elimination and \
re-running FBBT on the un-clamped box to avoid certifying \
a presolve-induced infeasibility on a feasible original."
);
x_l.copy_from_slice(&inner_x_l);
x_u.copy_from_slice(&inner_x_u);
for kept in row_kept_inner.iter_mut() {
*kept = true;
}
reduction_stack = ReductionStack::default();
let full_linear_rows: Vec<LinearRow> =
linear_row_map.iter().filter_map(|r| r.clone()).collect();
tighten_report = TightenReport::default();
if self.opts.bound_tightening && !full_linear_rows.is_empty() {
tighten_report = tighten_bounds(
&full_linear_rows,
&mut x_l,
&mut x_u,
self.opts.max_passes,
1e-12,
);
}
linear_rows = full_linear_rows;
if tighten_report.infeasible {
let crossing = (0..n).map(|j| x_l[j] - x_u[j]).fold(0.0_f64, f64::max);
let robust = crossing_is_certifiable(
&x_l,
&x_u,
certify_margin(self.opts.certify_tol),
);
x_l.copy_from_slice(&inner_x_l);
x_u.copy_from_slice(&inner_x_u);
if robust {
certified_infeasible = Some(InfeasibilityProof::BoundPropagation);
}
tracing::warn!(
target: "pounce::presolve",
crossing,
certified = robust,
"Phase 1 bound tightening on the rolled-back (un-clamped) \
box found the feasible region empty; its crossed bounds \
are being discarded."
);
}
let rerun_x_l_pre = x_l.clone();
let rerun_x_u_pre = x_u.clone();
let provider_borrow = provider.borrow();
report = crate::fbbt::run_fbbt(
&*provider_borrow,
n,
m_in,
&mut x_l,
&mut x_u,
&g_l_inner,
&g_u_inner,
Some(&row_kept_inner),
&cfg,
);
drop(provider_borrow);
if let Some(witness) = report.infeasibility_witness {
let provider_borrow = provider.borrow();
let robust = fbbt_infeasibility_survives_margin(
&*provider_borrow,
n,
m_in,
&inner_x_l,
&inner_x_u,
&g_l_inner,
&g_u_inner,
&row_kept_inner,
&cfg,
certify_margin(self.opts.certify_tol),
);
drop(provider_borrow);
if robust {
certified_infeasible =
Some(InfeasibilityProof::IntervalArithmetic { witness });
}
tracing::warn!(
target: "pounce::presolve",
witness,
certified = robust,
"FBBT still reports infeasibility on the un-clamped box; \
treating it as genuine."
);
x_l.copy_from_slice(&rerun_x_l_pre);
x_u.copy_from_slice(&rerun_x_u_pre);
}
} else if let Some(witness) = report.infeasibility_witness {
let provider_borrow = provider.borrow();
let robust = fbbt_infeasibility_survives_margin(
&*provider_borrow,
n,
m_in,
&inner_x_l,
&inner_x_u,
&g_l_inner,
&g_u_inner,
&row_kept_inner,
&cfg,
certify_margin(self.opts.certify_tol),
);
drop(provider_borrow);
if robust {
certified_infeasible =
Some(InfeasibilityProof::IntervalArithmetic { witness });
}
tracing::warn!(
target: "pounce::presolve",
witness,
certified = robust,
"FBBT reported a constraint's feasible range empty; its \
tightened bounds are undefined and are being discarded."
);
x_l.copy_from_slice(&fbbt_x_l_pre);
x_u.copy_from_slice(&fbbt_x_u_pre);
}
fbbt_report = Some(report);
}
}
if certified_infeasible.is_some()
&& witness_refutes_infeasibility(
&self.inner,
n,
m_in,
&inner_x_l,
&inner_x_u,
&g_l_inner,
&g_u_inner,
self.opts.certify_tol,
self.witness_rule,
)
{
tracing::warn!(
target: "pounce::presolve",
"presolve flagged the feasible region empty, but a point in the \
declared box satisfies every constraint — withdrawing the \
verdict and letting the solver decide."
);
certified_infeasible = None;
}
let warm_tol: Number = 1e-12;
let (z_l_warm, z_u_warm) = if self.opts.warm_z_bounds {
let v0 = self.opts.bound_mult_init_val;
let mut zl = vec![0.0; n];
let mut zu = vec![0.0; n];
for i in 0..n {
if x_l[i] > inner_x_l[i] + warm_tol {
zl[i] = v0;
}
if x_u[i] < inner_x_u[i] - warm_tol {
zu[i] = v0;
}
}
(zl, zu)
} else {
(vec![0.0; n], vec![0.0; n])
};
let mut n_dropped_rows: Index = 0;
if self.opts.redundant_constraint_removal {
let redundant_mask = find_redundant_rows(&linear_rows, &x_l, &x_u, 1e-9);
n_dropped_rows =
apply_redundant_verdicts(&linear_row_map, &redundant_mask, &mut row_kept_inner);
}
let licq_verdict = if self.opts.licq_check {
let eq_tol: Number = 1e-12;
let mut eq_rows: Vec<EqRow> = Vec::new();
for (i, &kept) in row_kept_inner.iter().enumerate() {
if !kept {
continue;
}
if (g_u_inner[i] - g_l_inner[i]).abs() > eq_tol {
continue;
}
use std::collections::BTreeSet;
let mut cols: BTreeSet<Index> = BTreeSet::new();
for &(j, v) in &by_row[i] {
if v != 0.0 {
cols.insert(j);
}
}
eq_rows.push(EqRow {
cols: cols.into_iter().collect(),
});
}
Some(licq_check(&eq_rows, info_inner.n))
} else {
None
};
let mut rows_kept: Vec<usize> = Vec::with_capacity(m_in);
let mut row_inner_to_outer = vec![usize::MAX; m_in];
for (i, &kept) in row_kept_inner.iter().enumerate() {
if kept {
row_inner_to_outer[i] = rows_kept.len();
rows_kept.push(i);
}
}
let m_out = rows_kept.len();
let mut jac_kept_idx = Vec::new();
let mut jac_irow_outer = Vec::new();
let mut jac_jcol_outer = Vec::new();
for k in 0..nnz_in {
let i_inner = if one_based {
(jac_irow_inner[k] - 1) as usize
} else {
jac_irow_inner[k] as usize
};
if i_inner >= m_in {
continue;
}
if !row_kept_inner[i_inner] {
continue;
}
let outer = row_inner_to_outer[i_inner];
let outer_row_index = if one_based {
(outer as Index) + 1
} else {
outer as Index
};
jac_irow_outer.push(outer_row_index);
jac_jcol_outer.push(jac_jcol_inner[k]);
jac_kept_idx.push(k);
}
let nnz_out = jac_kept_idx.len();
let g_l: Vec<Number> = rows_kept.iter().map(|&i| g_l_inner[i]).collect();
let g_u: Vec<Number> = rows_kept.iter().map(|&i| g_u_inner[i]).collect();
let info_outer = NlpInfo {
n: info_inner.n,
m: m_out as Index,
nnz_jac_g: nnz_out as Index,
nnz_h_lag: info_inner.nnz_h_lag,
index_style: info_inner.index_style,
};
self.state = Some(PresolveState {
info_inner,
info_outer,
bounds: CachedBounds { x_l, x_u, g_l, g_u },
rows_kept,
jac_kept_idx,
jac_irow_outer,
jac_jcol_outer,
tighten_report,
certified_infeasible,
fbbt_report,
n_dropped_rows,
licq_verdict,
z_l_warm,
z_u_warm,
scratch_g: vec![0.0; m_in],
scratch_jac: vec![0.0; nnz_in],
scratch_lambda: vec![0.0; m_in],
aux_diagnostics,
reduction_stack,
});
self.state.as_ref()
}
}
fn apply_redundant_verdicts(
linear_row_map: &[Option<LinearRow>],
redundant_mask: &[bool],
row_kept_inner: &mut [bool],
) -> Index {
let mut mask = redundant_mask.iter();
let mut n_dropped: Index = 0;
for (i, lr) in linear_row_map.iter().enumerate() {
if lr.is_some() && row_kept_inner[i] {
if *mask.next().unwrap_or(&false) {
row_kept_inner[i] = false;
n_dropped += 1;
}
}
}
n_dropped
}
#[allow(clippy::expect_used)]
impl TNLP for PresolveTnlp {
fn is_presolve_wrapper(&self) -> bool {
true
}
fn presolve_infeasibility_proof(&self) -> Option<InfeasibilityProof> {
self.certified_infeasible()
}
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
let s = self.ensure_init()?;
Some(s.info_outer)
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
let Some(s) = self.ensure_init() else {
return false;
};
b.x_l.copy_from_slice(&s.bounds.x_l);
b.x_u.copy_from_slice(&s.bounds.x_u);
b.g_l.copy_from_slice(&s.bounds.g_l);
b.g_u.copy_from_slice(&s.bounds.g_u);
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
let mut z_l_full = vec![0.0; sp.z_l.len()];
let mut z_u_full = vec![0.0; sp.z_u.len()];
let mut lambda_full = vec![0.0; m_in];
let ok = self.inner.borrow_mut().get_starting_point(StartingPoint {
init_x: sp.init_x,
x: sp.x,
init_z: sp.init_z,
z_l: &mut z_l_full,
z_u: &mut z_u_full,
init_lambda: sp.init_lambda,
lambda: &mut lambda_full,
});
if !ok {
return false;
}
sp.z_l.copy_from_slice(&z_l_full);
sp.z_u.copy_from_slice(&z_u_full);
let s = self.state.as_ref().expect("inited");
if sp.init_z && self.opts.warm_z_bounds {
for (i, &hint) in s.z_l_warm.iter().enumerate() {
if hint > 0.0 && sp.z_l[i] <= 0.0 {
sp.z_l[i] = hint;
}
}
for (i, &hint) in s.z_u_warm.iter().enumerate() {
if hint > 0.0 && sp.z_u[i] <= 0.0 {
sp.z_u[i] = hint;
}
}
}
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
sp.lambda[outer] = lambda_full[i_inner];
}
true
}
fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
self.inner.borrow_mut().eval_f(x, new_x)
}
fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
self.inner.borrow_mut().eval_grad_f(x, new_x, grad_f)
}
fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let s = self.state.as_mut().expect("inited");
if !self.inner.borrow_mut().eval_g(x, new_x, &mut s.scratch_g) {
return false;
}
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
g[outer] = s.scratch_g[i_inner];
}
true
}
fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
match mode {
SparsityRequest::Structure { irow, jcol } => {
let s = self.state.as_ref().expect("inited");
irow.copy_from_slice(&s.jac_irow_outer);
jcol.copy_from_slice(&s.jac_jcol_outer);
true
}
SparsityRequest::Values { values } => {
let s = self.state.as_mut().expect("inited");
if !self.inner.borrow_mut().eval_jac_g(
x,
new_x,
SparsityRequest::Values {
values: &mut s.scratch_jac,
},
) {
return false;
}
for (outer_k, &inner_k) in s.jac_kept_idx.iter().enumerate() {
values[outer_k] = s.scratch_jac[inner_k];
}
true
}
}
}
fn eval_h(
&mut self,
x: Option<&[Number]>,
new_x: bool,
obj_factor: Number,
lambda: Option<&[Number]>,
new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let lambda_full_opt = if let Some(lam) = lambda {
let s = self.state.as_mut().expect("inited");
for v in s.scratch_lambda.iter_mut() {
*v = 0.0;
}
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
s.scratch_lambda[i_inner] = lam[outer];
}
Some(&s.scratch_lambda[..])
} else {
None
};
let lam_ref: Option<&[Number]> = lambda_full_opt;
self.inner
.borrow_mut()
.eval_h(x, new_x, obj_factor, lam_ref, new_lambda, mode)
}
fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
let Some(_) = self.ensure_init() else {
self.inner
.borrow_mut()
.finalize_solution(sol, ip_data, ip_cq);
return;
};
let (g_full, mut lambda_full, n_inner, m_inner, nnz_inner, one_based) = {
let s = self.state.as_mut().expect("inited");
let ok_g = self
.inner
.borrow_mut()
.eval_g(sol.x, true, &mut s.scratch_g);
if !ok_g {
for v in s.scratch_g.iter_mut() {
*v = 0.0;
}
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
if i_inner < s.scratch_g.len() && outer < sol.g.len() {
s.scratch_g[i_inner] = sol.g[outer];
}
}
}
for v in s.scratch_lambda.iter_mut() {
*v = 0.0;
}
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
s.scratch_lambda[i_inner] = sol.lambda[outer];
}
(
s.scratch_g.clone(),
s.scratch_lambda.clone(),
s.info_inner.n as usize,
s.info_inner.m as usize,
s.info_inner.nnz_jac_g as usize,
matches!(s.info_inner.index_style, IndexStyle::Fortran),
)
};
let frames: Vec<reduction_frame::ReductionFrame> = {
let s = self.state.as_ref().expect("inited");
s.reduction_stack.iter_top_down().cloned().collect()
};
let mut z_l_full = sol.z_l.to_vec();
let mut z_u_full = sol.z_u.to_vec();
if !frames.is_empty() && m_inner > 0 {
let mut grad_f = vec![0.0; n_inner];
let ok_grad = self
.inner
.borrow_mut()
.eval_grad_f(sol.x, true, &mut grad_f);
let mut jac_irow_inner = vec![0 as Index; nnz_inner];
let mut jac_jcol_inner = vec![0 as Index; nnz_inner];
let ok_struct = if nnz_inner > 0 {
self.inner.borrow_mut().eval_jac_g(
None,
false,
SparsityRequest::Structure {
irow: &mut jac_irow_inner,
jcol: &mut jac_jcol_inner,
},
)
} else {
true
};
let mut jac_values = vec![0.0; nnz_inner];
let ok_vals = if nnz_inner > 0 {
self.inner.borrow_mut().eval_jac_g(
Some(sol.x),
false,
SparsityRequest::Values {
values: &mut jac_values,
},
)
} else {
true
};
if ok_grad && ok_struct && ok_vals {
let mut orig_to_compact = vec![usize::MAX; n_inner];
let mut n_cols = 0usize;
for frame in &frames {
for &c in &frame.fixed_vars {
if c < n_inner && orig_to_compact[c] == usize::MAX {
orig_to_compact[c] = n_cols;
n_cols += 1;
}
}
}
let mut jac_cols = vec![0.0; m_inner * n_cols];
for k in 0..nnz_inner {
let i = if one_based {
(jac_irow_inner[k] as isize - 1) as usize
} else {
jac_irow_inner[k] as usize
};
let j = if one_based {
(jac_jcol_inner[k] as isize - 1) as usize
} else {
jac_jcol_inner[k] as usize
};
if i < m_inner && j < n_inner {
let cc = orig_to_compact[j];
if cc != usize::MAX {
jac_cols[i * n_cols + cc] = jac_values[k];
}
}
}
for frame in &frames {
if let Ok(lam_dropped) = frame.recover_dropped_multipliers_cols(
&grad_f,
&jac_cols,
n_cols,
&orig_to_compact,
&lambda_full,
) {
for (idx, &r) in frame.dropped_rows.iter().enumerate() {
lambda_full[r] = lam_dropped[idx];
}
for &i in &frame.fixed_vars {
if i < z_l_full.len() {
z_l_full[i] = 0.0;
}
if i < z_u_full.len() {
z_u_full[i] = 0.0;
}
}
}
}
}
}
self.finalized_full_solution = Some((sol.x.to_vec(), lambda_full.clone()));
self.inner.borrow_mut().finalize_solution(
Solution {
status: sol.status,
x: sol.x,
z_l: &z_l_full,
z_u: &z_u_full,
g: &g_full,
lambda: &lambda_full,
obj_value: sol.obj_value,
},
ip_data,
ip_cq,
);
}
fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let mut inner_var = MetaData::default();
let mut inner_con = MetaData::default();
if !self
.inner
.borrow_mut()
.get_var_con_metadata(&mut inner_var, &mut inner_con)
{
return false;
}
*var = inner_var;
let s = self.state.as_ref().expect("inited");
let m_in = s.info_inner.m as usize;
*con = project_con_metadata(&inner_con, &s.rows_kept, m_in);
true
}
fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let s = self.state.as_ref().expect("inited");
let m_in = s.info_inner.m as usize;
let mut inner_g = vec![1.0; m_in];
let mut use_x = false;
let mut use_g = false;
let mut obj_scaling = 1.0;
let inner_x_scaling_len = req.x_scaling.len();
let mut inner_x = vec![1.0; inner_x_scaling_len];
let ok = self
.inner
.borrow_mut()
.get_scaling_parameters(ScalingRequest {
obj_scaling: &mut obj_scaling,
use_x_scaling: &mut use_x,
x_scaling: &mut inner_x,
use_g_scaling: &mut use_g,
g_scaling: &mut inner_g,
});
if !ok {
return false;
}
*req.obj_scaling = obj_scaling;
*req.use_x_scaling = use_x;
*req.use_g_scaling = use_g;
req.x_scaling.copy_from_slice(&inner_x);
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
req.g_scaling[outer] = inner_g[i_inner];
}
true
}
fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.borrow_mut().get_variables_linearity(types)
}
fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner
.borrow_mut()
.get_objective_variables_linearity(types)
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
let Some(_) = self.ensure_init() else {
return false;
};
let m_in = self.state.as_ref().expect("inited").info_inner.m as usize;
let mut full = vec![Linearity::NonLinear; m_in];
if !self.inner.borrow_mut().get_constraints_linearity(&mut full) {
return false;
}
let s = self.state.as_ref().expect("inited");
for (outer, &i_inner) in s.rows_kept.iter().enumerate() {
types[outer] = full[i_inner];
}
true
}
fn get_number_of_nonlinear_variables(&mut self) -> Index {
self.inner.borrow_mut().get_number_of_nonlinear_variables()
}
fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
self.inner
.borrow_mut()
.get_list_of_nonlinear_variables(pos_nonlin_vars)
}
fn intermediate_callback(
&mut self,
stats: IterStats,
ip_data: &IpoptData,
ip_cq: &IpoptCq,
) -> bool {
self.inner
.borrow_mut()
.intermediate_callback(stats, ip_data, ip_cq)
}
fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
let Some(_) = self.ensure_init() else {
self.inner.borrow_mut().finalize_metadata(var, con);
return;
};
let s = self.state.as_ref().expect("inited");
let m_in = s.info_inner.m as usize;
let con_full = expand_con_metadata(con, &s.rows_kept, m_in);
self.inner.borrow_mut().finalize_metadata(var, &con_full);
}
}
fn project_con_metadata(inner: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
let mut out = MetaData::default();
for (k, v) in &inner.strings {
out.strings.insert(
k.clone(),
if v.len() == m_in {
rows_kept.iter().map(|&i| v[i].clone()).collect()
} else {
v.clone()
},
);
}
for (k, v) in &inner.integers {
out.integers.insert(
k.clone(),
if v.len() == m_in {
rows_kept.iter().map(|&i| v[i]).collect()
} else {
v.clone()
},
);
}
for (k, v) in &inner.numerics {
out.numerics.insert(
k.clone(),
if v.len() == m_in {
rows_kept.iter().map(|&i| v[i]).collect()
} else {
v.clone()
},
);
}
out
}
fn expand_con_metadata(outer: &MetaData, rows_kept: &[usize], m_in: usize) -> MetaData {
let m_out = rows_kept.len();
let mut full = MetaData::default();
for (k, v) in &outer.strings {
let mut buf: Vec<String> = vec![String::new(); m_in];
if v.len() == m_out {
for (outer_i, val) in v.iter().enumerate() {
buf[rows_kept[outer_i]] = val.clone();
}
full.strings.insert(k.clone(), buf);
} else {
full.strings.insert(k.clone(), v.clone());
}
}
for (k, v) in &outer.integers {
let mut buf: Vec<Index> = vec![0; m_in];
if v.len() == m_out {
for (outer_i, &val) in v.iter().enumerate() {
buf[rows_kept[outer_i]] = val;
}
full.integers.insert(k.clone(), buf);
} else {
full.integers.insert(k.clone(), v.clone());
}
}
for (k, v) in &outer.numerics {
let mut buf: Vec<Number> = vec![0.0; m_in];
if v.len() == m_out {
for (outer_i, &val) in v.iter().enumerate() {
buf[rows_kept[outer_i]] = val;
}
full.numerics.insert(k.clone(), buf);
} else {
full.numerics.insert(k.clone(), v.clone());
}
}
full
}
pub fn register(reg: &RegisteredOptions) -> Result<(), SolverException> {
register_options(reg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sentinel_against_a_real_bound_never_certifies_a_crossing() {
let tol = 1e-8;
assert!(
!crossing_is_certifiable(&[-1e19], &[-5e20], tol),
"`x <= -5e20` with no lower bound is feasible, not a crossed box"
);
assert!(
!crossing_is_certifiable(&[5e20], &[1e19], tol),
"`x >= 5e20` with no upper bound is feasible too"
);
assert!(crossing_is_certifiable(&[5.0], &[3.0], tol));
assert!(!crossing_is_certifiable(&[1.0 + 1e-13], &[1.0], tol));
}
#[test]
fn the_row_margin_tracks_a_bound_past_the_opposite_sentinel() {
let tol = 1e-8;
let m = row_margin_for(-1e19, -5e20, tol);
assert!(
(m - tol * 5e20).abs() <= tol * 5e20 * 1e-12,
"margin should be tol*5e20 = {}, got {m}",
tol * 5e20
);
assert_eq!(row_margin_for(-1e19, 1e19, tol), tol);
assert_eq!(row_margin_for(-2.0, 7.0, tol), tol * 7.0);
}
struct Probe;
impl TNLP for Probe {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: 0,
nnz_jac_g: 0,
nnz_h_lag: 1,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, _b: BoundsInfo<'_>) -> bool {
true
}
fn get_starting_point(&mut self, _sp: StartingPoint<'_>) -> bool {
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
true
}
fn eval_g(&mut self, _x: &[Number], _new_x: bool, _g: &mut [Number]) -> bool {
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
_mode: SparsityRequest<'_>,
) -> bool {
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
struct NoopProvider;
impl ExpressionProvider for NoopProvider {}
#[test]
fn c1_redundancy_mask_realigned_after_phase0_drop() {
let lr = |lo: Number| {
Some(LinearRow {
entries: vec![(0, 1.0)],
lo,
hi: lo,
})
};
let linear_row_map = vec![lr(0.0), lr(1.0), lr(2.0)];
let row_kept = vec![false, true, true];
let mask = vec![false, true];
let mut kept_new = row_kept.clone();
let n = apply_redundant_verdicts(&linear_row_map, &mask, &mut kept_new);
assert_eq!(
kept_new,
vec![false, true, false],
"verdict must land on inner row 2, not its predecessor"
);
assert_eq!(n, 1);
let mut buggy = row_kept.clone();
let mut it = mask.iter();
for (i, l) in linear_row_map.iter().enumerate() {
if l.is_some() && *it.next().unwrap_or(&false) {
buggy[i] = false;
}
}
assert_eq!(buggy, vec![false, false, true]);
assert_ne!(buggy, kept_new);
}
#[test]
fn disabled_returns_inner_unchanged() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
let opts = PresolveOptions {
enabled: false,
..PresolveOptions::defaults()
};
let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
assert!(Rc::ptr_eq(&inner, &wrapped));
}
#[test]
fn enabled_wraps_and_forwards() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
let opts = PresolveOptions {
enabled: true,
..PresolveOptions::defaults()
};
let wrapped = wrap_with_presolve(Rc::clone(&inner), opts).unwrap();
assert!(!Rc::ptr_eq(&inner, &wrapped));
let info = wrapped.borrow_mut().get_nlp_info().unwrap();
assert_eq!(info.n, 1);
assert_eq!(info.m, 0);
}
#[test]
fn provider_wrapper_does_not_nest_an_existing_presolve_wrapper() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
let opts = PresolveOptions {
enabled: true,
..PresolveOptions::defaults()
};
let manual = wrap_with_presolve(inner, opts.clone()).unwrap();
let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(NoopProvider));
let wrapped = wrap_with_presolve_provider(manual.clone(), provider, opts).unwrap();
assert!(Rc::ptr_eq(&manual, &wrapped));
}
#[test]
fn register_options_roundtrip() {
let reg = RegisteredOptions::default();
register_options(®).unwrap();
let opt = reg.get_option("presolve").expect("presolve registered");
assert_eq!(opt.name, "presolve");
}
#[test]
fn auxiliary_phase0_noop_when_disabled() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
let opts = PresolveOptions {
enabled: true,
auxiliary: false,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 1);
assert_eq!(info.m, 0);
let diag = wrapped.auxiliary_diagnostics();
assert_eq!(diag.blocks_eliminated, 0);
assert_eq!(diag.vars_eliminated, 0);
assert_eq!(diag.rows_eliminated, 0);
}
#[test]
fn auxiliary_phase0_noop_when_enabled_no_algos_yet() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(Probe));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Aggressive,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 1);
assert_eq!(info.m, 0);
let diag = wrapped.auxiliary_diagnostics();
assert_eq!(diag.blocks_eliminated, 0);
assert!(diag.rejection_reasons.is_empty());
}
struct TwoVarSquareEq;
impl TNLP for TwoVarSquareEq {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 2,
nnz_jac_g: 4,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
for v in b.x_l.iter_mut() {
*v = -1e19;
}
for v in b.x_u.iter_mut() {
*v = 1e19;
}
b.g_l[0] = 3.0;
b.g_u[0] = 3.0;
b.g_l[1] = 1.0;
b.g_u[1] = 1.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 0.0;
sp.x[1] = 0.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for v in g.iter_mut() {
*v = 0.0;
}
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0] + x[1];
g[1] = x[0] - x[1];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 0, 1, 1]);
jcol.copy_from_slice(&[0, 1, 0, 1]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
#[test]
fn phase0_via_tnlp_eliminates_square_block() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 2);
assert_eq!(info.m, 0);
let diag = wrapped.auxiliary_diagnostics();
assert_eq!(diag.blocks_eliminated, 1);
assert_eq!(diag.vars_eliminated, 2);
assert_eq!(diag.rows_eliminated, 2);
let bounds = wrapped.cached_bounds().expect("inited");
assert!((bounds.x_l[0] - 2.0).abs() < 1e-12);
assert!((bounds.x_u[0] - 2.0).abs() < 1e-12);
assert!((bounds.x_l[1] - 1.0).abs() < 1e-12);
assert!((bounds.x_u[1] - 1.0).abs() < 1e-12);
}
struct SquareEqWithTags {
inner: TwoVarSquareEq,
offer_obj_tags: bool,
}
impl TNLP for SquareEqWithTags {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
self.inner.get_nlp_info()
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
self.inner.get_bounds_info(b)
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
self.inner.get_starting_point(sp)
}
fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
self.inner.eval_f(x, new_x)
}
fn eval_grad_f(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
self.inner.eval_grad_f(x, new_x, g)
}
fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
self.inner.eval_g(x, new_x, g)
}
fn eval_jac_g(
&mut self,
x: Option<&[Number]>,
new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
self.inner.eval_jac_g(x, new_x, mode)
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_constraints_linearity(types)
}
fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::NonLinear;
types[1] = Linearity::NonLinear;
true
}
fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
if !self.offer_obj_tags {
return false;
}
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
self.inner.finalize_solution(sol, d, q)
}
}
#[test]
fn phase0_objective_scoped_tags_dont_block_constraint_nonlinear_elimination() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SquareEqWithTags {
inner: TwoVarSquareEq,
offer_obj_tags: true,
}));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(
info.m, 0,
"objective-free block must still be eliminated when only the \
global tags say NonLinear"
);
assert_eq!(wrapped.auxiliary_diagnostics().vars_eliminated, 2);
}
#[test]
fn phase0_global_tags_fallback_remains_conservative() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SquareEqWithTags {
inner: TwoVarSquareEq,
offer_obj_tags: false,
}));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(
info.m, 2,
"global-only tags fall back to the conservative union"
);
assert_eq!(wrapped.auxiliary_diagnostics().vars_eliminated, 0);
}
#[test]
fn phase0_via_tnlp_disabled_is_pass_through() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
let opts = PresolveOptions {
enabled: true,
auxiliary: false,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 2);
assert_eq!(info.m, 2);
let diag = wrapped.auxiliary_diagnostics();
assert_eq!(diag.blocks_eliminated, 0);
}
#[test]
fn phase0_via_tnlp_diagnostics_flag_does_not_break_solve() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_diagnostics: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.m, 0);
let diag = wrapped.auxiliary_diagnostics();
assert_eq!(diag.blocks_eliminated, 1);
}
#[test]
fn phase0_via_tnlp_no_infeasible_with_default_bound_tightening() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoVarSquareEq));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
bound_tightening: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.m, 0); let bounds = wrapped.cached_bounds().expect("inited");
for i in 0..(info.n as usize) {
assert!(
bounds.x_l[i] <= bounds.x_u[i] + 1e-12,
"x_l[{i}] = {} > x_u[{i}] = {}",
bounds.x_l[i],
bounds.x_u[i]
);
}
let rpt = wrapped.tighten_report();
assert!(!rpt.infeasible, "Phase 1 falsely flagged infeasibility");
}
struct RecordingTwoVar {
rec: Rc<RefCell<Option<(Vec<Number>, Vec<Number>)>>>,
}
impl TNLP for RecordingTwoVar {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 2,
nnz_jac_g: 4,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
for v in b.x_l.iter_mut() {
*v = -1e19;
}
for v in b.x_u.iter_mut() {
*v = 1e19;
}
b.g_l[0] = 3.0;
b.g_u[0] = 3.0;
b.g_l[1] = 1.0;
b.g_u[1] = 1.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 0.0;
sp.x[1] = 0.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for v in g.iter_mut() {
*v = 0.0;
}
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0] + x[1];
g[1] = x[0] - x[1];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 0, 1, 1]);
jcol.copy_from_slice(&[0, 1, 0, 1]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
*self.rec.borrow_mut() = Some((sol.z_l.to_vec(), sol.z_u.to_vec()));
}
}
#[test]
fn phase0_finalize_zeroes_bound_multipliers_at_fixed_vars() {
let rec = Rc::new(RefCell::new(None));
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(RecordingTwoVar {
rec: Rc::clone(&rec),
}));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 2, "variable count unchanged (clamp, not reduce)");
assert_eq!(info.m, 0, "both equality rows dropped by Phase 0");
let x = [2.0, 1.0];
let z_l = [7.0, 0.0];
let z_u = [0.0, 3.0];
let g: [Number; 0] = [];
let lambda: [Number; 0] = [];
let sol = Solution {
status: pounce_nlp::alg_types::SolverReturn::Success,
x: &x,
z_l: &z_l,
z_u: &z_u,
g: &g,
lambda: &lambda,
obj_value: 0.0,
};
wrapped.finalize_solution(sol, &IpoptData::default(), &IpoptCq::default());
let (got_zl, got_zu) = rec.borrow().clone().expect("inner finalize ran");
assert_eq!(
got_zl,
vec![0.0, 0.0],
"z_l must be zeroed at aux-fixed vars (H10)"
);
assert_eq!(
got_zu,
vec![0.0, 0.0],
"z_u must be zeroed at aux-fixed vars (H10)"
);
}
struct GFailRecordingVar {
rec_g: Rc<RefCell<Option<Vec<Number>>>>,
fail_g: Rc<RefCell<bool>>,
}
impl TNLP for GFailRecordingVar {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 2,
nnz_jac_g: 4,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
for v in b.x_l.iter_mut() {
*v = -1e19;
}
for v in b.x_u.iter_mut() {
*v = 1e19;
}
b.g_l[0] = 3.0;
b.g_u[0] = 3.0;
b.g_l[1] = 1.0;
b.g_u[1] = 1.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 0.0;
sp.x[1] = 0.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for v in g.iter_mut() {
*v = 0.0;
}
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
if *self.fail_g.borrow() {
for v in g.iter_mut() {
*v = 999.0;
}
return false;
}
g[0] = x[0] + x[1];
g[1] = x[0] - x[1];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 0, 1, 1]);
jcol.copy_from_slice(&[0, 1, 0, 1]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
*self.rec_g.borrow_mut() = Some(sol.g.to_vec());
}
}
#[test]
fn finalize_does_not_forward_stale_g_when_eval_g_fails() {
let rec_g = Rc::new(RefCell::new(None));
let fail_g = Rc::new(RefCell::new(false));
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(GFailRecordingVar {
rec_g: Rc::clone(&rec_g),
fail_g: Rc::clone(&fail_g),
}));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.m, 0, "both equality rows dropped by Phase 0");
*fail_g.borrow_mut() = true;
let x = [2.0, 1.0];
let z_l = [0.0, 0.0];
let z_u = [0.0, 0.0];
let g: [Number; 0] = [];
let lambda: [Number; 0] = [];
let sol = Solution {
status: pounce_nlp::alg_types::SolverReturn::Success,
x: &x,
z_l: &z_l,
z_u: &z_u,
g: &g,
lambda: &lambda,
obj_value: 0.0,
};
wrapped.finalize_solution(sol, &IpoptData::default(), &IpoptCq::default());
let got_g = rec_g.borrow().clone().expect("inner finalize ran");
assert_eq!(
got_g,
vec![0.0, 0.0],
"failed eval_g must not forward stale/garbage constraint values",
);
}
struct SentinelLowerBoundRow;
impl TNLP for SentinelLowerBoundRow {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: 1,
nnz_jac_g: 1,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = 0.0;
b.x_u[0] = 1e-9;
b.g_l[0] = -1e19; b.g_u[0] = -5e20; true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 5e-10;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 0.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = -1e30 * x[0];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow[0] = 0;
jcol[0] = 0;
}
SparsityRequest::Values { values } => {
values[0] = -1e30;
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
#[test]
fn absent_bound_sentinel_does_not_manufacture_a_violation() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SentinelLowerBoundRow));
assert!(
witness_refutes_infeasibility(
&inner,
1,
1,
&[0.0],
&[1e-9],
&[-1e19],
&[-5e20],
1e-8,
WitnessRule::SolverAcceptance,
),
"x0 = 5e-10 puts the row exactly on its only real bound — that is a \
witness, and the -1e19 sentinel standing in for the absent lower \
bound must not turn it into a 4.9e20 violation"
);
}
#[test]
fn a_real_bound_beyond_the_sentinel_still_blocks_the_witness() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SentinelLowerBoundRow));
assert!(
!witness_refutes_infeasibility(
&inner,
1,
1,
&[4e-10],
&[5e-10],
&[-1e19],
&[-5.1e20],
1e-8,
WitnessRule::SolverAcceptance,
),
"no point in [4e-10, 5e-10] satisfies -1e30*x <= -5.1e20, so there \
is no witness — a real bound of magnitude 5.1e20 must not be \
discarded as the infinity sentinel"
);
}
struct MinXWithLowerRow;
impl TNLP for MinXWithLowerRow {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: 1,
nnz_jac_g: 1,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = 0.0;
b.x_u[0] = 10.0;
b.g_l[0] = 2.0; b.g_u[0] = 1e19;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 5.0;
}
true
}
fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
Some(x[0])
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 1.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow[0] = 0;
jcol[0] = 0;
}
SparsityRequest::Values { values } => {
values[0] = 1.0;
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
#[test]
fn dropped_row_dual_lands_on_bound_not_row() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(MinXWithLowerRow));
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
redundant_constraint_removal: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(
wrapped.n_dropped_rows(),
1,
"the `x ≥ 2` row must be dropped"
);
assert_eq!(info.m, 0, "reduced problem has no rows");
let b = wrapped.cached_bounds().expect("inited");
assert!(
(b.x_l[0] - 2.0).abs() < 1e-12,
"x_l tightened to 2 by the row, got {}",
b.x_l[0]
);
let x = [2.0];
let z_l = [1.0];
let z_u = [0.0];
let g: [Number; 0] = [];
let lambda: [Number; 0] = [];
let sol = Solution {
status: pounce_nlp::alg_types::SolverReturn::Success,
x: &x,
z_l: &z_l,
z_u: &z_u,
g: &g,
lambda: &lambda,
obj_value: 2.0,
};
wrapped.finalize_solution(sol, &IpoptData::default(), &IpoptCq::default());
let (xf, lamf) = wrapped.finalized_full_solution().expect("finalized");
assert!((xf[0] - 2.0).abs() < 1e-12, "primal x = 2");
assert_eq!(lamf.len(), 1, "full-space lambda regains the original row");
assert!(
lamf[0].abs() < 1e-12,
"M24: dropped-row λ stays 0 (dual sits on z_l instead), got {}",
lamf[0]
);
let grad_f = 1.0;
let jac = 1.0; let stat = grad_f - jac * lamf[0] - z_l[0] + z_u[0];
assert!(stat.abs() < 1e-12, "KKT stationarity residual {stat}");
}
struct TwoContradictoryRows;
impl TNLP for TwoContradictoryRows {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: 2,
nnz_jac_g: 2,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = 0.0;
b.x_u[0] = 10.0;
b.g_l[0] = 5.0; b.g_u[0] = 1e19;
b.g_l[1] = -1e19; b.g_u[1] = 3.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 4.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 0.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0];
g[1] = x[0];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 1]);
jcol.copy_from_slice(&[0, 0]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
#[test]
fn phase1_infeasible_restores_valid_box_for_ipm() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TwoContradictoryRows));
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
auxiliary: false,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
let _info = wrapped.get_nlp_info().expect("init ok");
assert!(
wrapped.tighten_report().infeasible,
"Phase 1 must still flag the empty feasible region"
);
assert_eq!(
wrapped.certified_infeasible(),
Some(InfeasibilityProof::BoundPropagation),
"a Phase-1 contradiction on an un-clamped box must be certified"
);
let b = wrapped.cached_bounds().expect("inited");
assert!(
b.x_l[0] <= b.x_u[0] + 1e-12,
"M25: bounds handed to IPM must be valid, got x_l={} > x_u={}",
b.x_l[0],
b.x_u[0]
);
assert!(
(b.x_l[0] - 0.0).abs() < 1e-12 && (b.x_u[0] - 10.0).abs() < 1e-12,
"box restored to the original [0, 10], got [{}, {}]",
b.x_l[0],
b.x_u[0]
);
}
struct FbbtPartialThenInfeasible;
impl TNLP for FbbtPartialThenInfeasible {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: 2,
nnz_jac_g: 2,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = 0.0;
b.x_u[0] = 1.0;
b.g_l[0] = 0.3;
b.g_u[0] = 0.7;
b.g_l[1] = 5.0;
b.g_u[1] = 5.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 0.5;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 0.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0];
g[1] = x[0];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 1]);
jcol.copy_from_slice(&[0, 0]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0]);
}
}
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
struct VarTapeProvider;
impl ExpressionProvider for VarTapeProvider {
fn constraint_expression(
&self,
i: usize,
) -> Option<pounce_nlp::expression_provider::FbbtTape> {
use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
if i < 2 {
Some(FbbtTape {
ops: vec![FbbtOp::Var(0)],
})
} else {
None
}
}
}
#[test]
fn fbbt_infeasibility_discards_corrupted_bounds() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FbbtPartialThenInfeasible));
let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(VarTapeProvider));
let opts = PresolveOptions {
enabled: true,
fbbt: true,
..PresolveOptions::defaults()
};
let mut wrapped =
PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.n, 1);
assert_eq!(info.m, 2, "nonlinear rows are not dropped by presolve");
let rpt = wrapped.fbbt_report().expect("fbbt ran");
assert_eq!(
wrapped.certified_infeasible(),
Some(InfeasibilityProof::IntervalArithmetic { witness: 1 }),
"an FBBT witness on an un-clamped box must be certified"
);
assert_eq!(
rpt.infeasibility_witness,
Some(1),
"row 1 (`x = 5`) is the infeasibility witness"
);
let mut x_l = vec![0.0; info.n as usize];
let mut x_u = vec![0.0; info.n as usize];
let mut g_l = vec![0.0; info.m as usize];
let mut g_u = vec![0.0; info.m as usize];
assert!(wrapped.get_bounds_info(BoundsInfo {
x_l: &mut x_l,
x_u: &mut x_u,
g_l: &mut g_l,
g_u: &mut g_u,
}));
assert_eq!(
(x_l[0], x_u[0]),
(0.0, 1.0),
"FBBT's undefined-on-infeasibility bounds must not reach the IPM (H12)"
);
}
struct AuxClampBreaksKeptNonlinearRow;
impl TNLP for AuxClampBreaksKeptNonlinearRow {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 3,
m: 3,
nnz_jac_g: 6,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = -1e19;
b.x_u[0] = 1e19;
b.x_l[1] = -1e19;
b.x_u[1] = 1e19;
b.x_l[2] = 0.0;
b.x_u[2] = 1.0;
b.g_l[0] = 3.0;
b.g_u[0] = 3.0;
b.g_l[1] = 1.0;
b.g_u[1] = 1.0;
b.g_l[2] = 20.0;
b.g_u[2] = 20.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 0.0;
sp.x[1] = 0.0;
sp.x[2] = 0.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for v in g.iter_mut() {
*v = 0.0;
}
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0] + x[1];
g[1] = x[0] - x[1];
g[2] = x[0] + x[2];
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 0, 1, 1, 2, 2]);
jcol.copy_from_slice(&[0, 1, 0, 1, 0, 2]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0, 1.0, -1.0, 1.0, 1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::Linear;
types[1] = Linearity::Linear;
types[2] = Linearity::NonLinear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
struct AuxBreakProvider;
impl ExpressionProvider for AuxBreakProvider {
fn constraint_expression(
&self,
i: usize,
) -> Option<pounce_nlp::expression_provider::FbbtTape> {
use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
if i == 2 {
Some(FbbtTape {
ops: vec![FbbtOp::Var(0), FbbtOp::Var(2), FbbtOp::Add(0, 1)],
})
} else {
None
}
}
}
#[test]
fn fbbt_infeasibility_with_aux_clamp_rolls_back_phase0() {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(AuxClampBreaksKeptNonlinearRow));
let provider: Rc<RefCell<dyn ExpressionProvider>> = Rc::new(RefCell::new(AuxBreakProvider));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
bound_tightening: true,
fbbt: true,
..PresolveOptions::defaults()
};
let mut wrapped =
PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(
wrapped.auxiliary_diagnostics().vars_eliminated,
2,
"Phase 0 must have clamped the linear block — otherwise the \
rollback path is never exercised"
);
assert_eq!(
info.m, 3,
"FBBT infeasibility under an aux clamp must roll Phase 0 back, \
restoring the dropped rows (got m={})",
info.m
);
let rpt = wrapped.fbbt_report().expect("fbbt ran");
assert!(
rpt.infeasibility_witness.is_none(),
"FBBT must not witness infeasibility on the un-clamped box, got {:?}",
rpt.infeasibility_witness
);
assert_eq!(
wrapped.certified_infeasible(),
None,
"a presolve-manufactured infeasibility must never be certified — \
this model is feasible"
);
let mut x_l = vec![0.0; info.n as usize];
let mut x_u = vec![0.0; info.n as usize];
let mut g_l = vec![0.0; info.m as usize];
let mut g_u = vec![0.0; info.m as usize];
assert!(wrapped.get_bounds_info(BoundsInfo {
x_l: &mut x_l,
x_u: &mut x_u,
g_l: &mut g_l,
g_u: &mut g_u,
}));
for i in 0..(info.n as usize) {
assert!(
x_l[i] <= x_u[i] + 1e-12,
"bounds handed to IPM must be valid: x_l[{i}]={} > x_u[{i}]={}",
x_l[i],
x_u[i]
);
}
assert!(
x_l[0] > 2.0 + 1e-6,
"x0 must no longer be clamped to the aux value 2 (got x_l[0]={})",
x_l[0]
);
}
struct WrongRootClampBreaksFeasibleOriginal;
impl TNLP for WrongRootClampBreaksFeasibleOriginal {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 2,
nnz_jac_g: 3,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = -10.0;
b.x_u[0] = 10.0;
b.x_l[1] = 0.0;
b.x_u[1] = 2.0;
b.g_l[0] = 4.0;
b.g_u[0] = 4.0;
b.g_l[1] = 3.0;
b.g_u[1] = 3.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = -1.0;
sp.x[1] = 0.0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for v in g.iter_mut() {
*v = 0.0;
}
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = x[0] * x[0];
g[1] = x[0] + x[1];
true
}
fn eval_jac_g(
&mut self,
x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 1, 1]);
jcol.copy_from_slice(&[0, 0, 1]);
}
SparsityRequest::Values { values } => {
let x0 = x.map(|x| x[0]).unwrap_or(-1.0);
values.copy_from_slice(&[2.0 * x0, 1.0, 1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types[0] = Linearity::NonLinear;
types[1] = Linearity::NonLinear;
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
struct WrongRootKeptRowProvider;
impl ExpressionProvider for WrongRootKeptRowProvider {
fn constraint_expression(
&self,
i: usize,
) -> Option<pounce_nlp::expression_provider::FbbtTape> {
use pounce_nlp::expression_provider::{FbbtOp, FbbtTape};
if i == 1 {
Some(FbbtTape {
ops: vec![FbbtOp::Var(0), FbbtOp::Var(1), FbbtOp::Add(0, 1)],
})
} else {
None
}
}
}
#[test]
fn fbbt_rollback_rescues_feasible_original_from_wrong_root_clamp() {
let inner: Rc<RefCell<dyn TNLP>> =
Rc::new(RefCell::new(WrongRootClampBreaksFeasibleOriginal));
let provider: Rc<RefCell<dyn ExpressionProvider>> =
Rc::new(RefCell::new(WrongRootKeptRowProvider));
let opts = PresolveOptions {
enabled: true,
auxiliary: true,
auxiliary_coupling: AuxiliaryCouplingPolicy::Safe,
bound_tightening: true,
fbbt: true,
..PresolveOptions::defaults()
};
let mut wrapped =
PresolveTnlp::with_expression_provider(Rc::clone(&inner), Rc::clone(&provider), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(
wrapped.auxiliary_diagnostics().vars_eliminated,
1,
"Phase 0 must have solved and clamped the x² = 4 block — \
otherwise the wrong-root path is never exercised"
);
assert_eq!(
info.m, 2,
"wrong-root clamp on a feasible original must trigger the F6 \
rollback and restore the dropped block row (got m={})",
info.m
);
let rpt = wrapped.fbbt_report().expect("fbbt ran");
assert!(
rpt.infeasibility_witness.is_none(),
"feasible original: FBBT must not witness infeasibility after \
the rollback, got {:?}",
rpt.infeasibility_witness
);
let mut x_l = vec![0.0; info.n as usize];
let mut x_u = vec![0.0; info.n as usize];
let mut g_l = vec![0.0; info.m as usize];
let mut g_u = vec![0.0; info.m as usize];
assert!(wrapped.get_bounds_info(BoundsInfo {
x_l: &mut x_l,
x_u: &mut x_u,
g_l: &mut g_l,
g_u: &mut g_u,
}));
for i in 0..(info.n as usize) {
assert!(
x_l[i] <= x_u[i] + 1e-12,
"bounds handed to IPM must be valid: x_l[{i}]={} > x_u[{i}]={}",
x_l[i],
x_u[i]
);
}
assert!(
x_u[0] - x_l[0] > 1e-6,
"x must no longer be clamped (got [{}, {}])",
x_l[0],
x_u[0]
);
assert!(
x_l[0] <= 2.0 + 1e-9 && 2.0 <= x_u[0] + 1e-9,
"the feasible root x = 2 must survive in the IPM's box, got \
[{}, {}]",
x_l[0],
x_u[0]
);
assert!(
x_l[1] <= 1.0 + 1e-9 && 1.0 <= x_u[1] + 1e-9,
"the feasible y = 1 must survive in the IPM's box, got [{}, {}]",
x_l[1],
x_u[1]
);
}
#[test]
fn con_metadata_per_row_vector_round_trips_under_row_drop() {
let m_in = 3;
let rows_kept = vec![0usize, 2]; let mut inner = MetaData::default();
inner.strings.insert(
"names".to_string(),
vec!["c0".to_string(), "c1".to_string(), "c2".to_string()],
);
inner.integers.insert("flags".to_string(), vec![10, 11, 12]);
inner
.numerics
.insert("weights".to_string(), vec![1.0, 2.0, 3.0]);
let reduced = project_con_metadata(&inner, &rows_kept, m_in);
assert_eq!(
reduced.strings["names"],
vec!["c0".to_string(), "c2".to_string()]
);
assert_eq!(reduced.integers["flags"], vec![10, 12]);
assert_eq!(reduced.numerics["weights"], vec![1.0, 3.0]);
let restored = expand_con_metadata(&reduced, &rows_kept, m_in);
assert_eq!(
restored.strings["names"],
vec!["c0".to_string(), String::new(), "c2".to_string()]
);
assert_eq!(restored.integers["flags"], vec![10, 0, 12]);
assert_eq!(restored.numerics["weights"], vec![1.0, 0.0, 3.0]);
}
#[test]
fn con_metadata_length_heuristic_misfires_on_coincidental_global() {
let m_in = 3;
let rows_kept = vec![0usize, 2]; let mut inner = MetaData::default();
inner
.integers
.insert("global_triple".to_string(), vec![100, 200, 300]);
let reduced = project_con_metadata(&inner, &rows_kept, m_in);
assert_eq!(
reduced.integers["global_triple"],
vec![100, 300],
"documents the L46 length-heuristic misfire on a coincidental global vector"
);
let mut inner2 = MetaData::default();
inner2
.numerics
.insert("two_globals".to_string(), vec![1.5, 2.5]);
let reduced2 = project_con_metadata(&inner2, &rows_kept, m_in);
assert_eq!(reduced2.numerics["two_globals"], vec![1.5, 2.5]);
}
struct WitnessProbeRows {
coeff: Vec<Number>,
offset: Vec<Number>,
g_l: Vec<Number>,
g_u: Vec<Number>,
x0: Number,
}
impl TNLP for WitnessProbeRows {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 1,
m: self.coeff.len() as Index,
nnz_jac_g: self.coeff.len() as Index,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l[0] = 0.0;
b.x_u[0] = 1.0;
b.g_l.copy_from_slice(&self.g_l);
b.g_u.copy_from_slice(&self.g_u);
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = self.x0;
}
true
}
fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
Some(0.0)
}
fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 0.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
for ((out, &c), &o) in g.iter_mut().zip(&self.coeff).zip(&self.offset) {
*out = c * x[0] + o;
}
true
}
fn eval_jac_g(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
for (i, r) in irow.iter_mut().enumerate() {
*r = i as Index;
}
jcol.fill(0);
}
SparsityRequest::Values { values } => values.copy_from_slice(&self.coeff),
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types.fill(Linearity::Linear);
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
fn refutes(model: WitnessProbeRows, rule: WitnessRule) -> bool {
let (g_l, g_u) = (model.g_l.clone(), model.g_u.clone());
let m = g_l.len();
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model));
witness_refutes_infeasibility(&inner, 1, m, &[0.0], &[1.0], &g_l, &g_u, 1e-8, rule)
}
#[test]
fn down_scaled_rows_only_defeat_the_clamped_witness_form() {
let model = |_s: Number| WitnessProbeRows {
coeff: vec![1e-12, 1e-12],
offset: vec![0.0, 0.0],
g_l: vec![0.2e-12, 0.8e-12],
g_u: vec![0.2e-12, 0.8e-12],
x0: 0.5,
};
assert!(
refutes(model(1e-12), WitnessRule::SolverAcceptance),
"the clamped form accepts every point of the box at this row scale \
— this is the behavior gh#391 is about, pinned so the contrast \
below stays meaningful"
);
assert!(
!refutes(model(1e-12), WitnessRule::DeclaredRowRelative),
"0.6 of the row's own declared magnitude is a violation at every \
scale; the strict rule must not withdraw the proof"
);
}
#[test]
fn unit_scale_rows_refute_under_neither_form() {
let model = || WitnessProbeRows {
coeff: vec![1.0, 1.0],
offset: vec![0.0, 0.0],
g_l: vec![0.2, 0.8],
g_u: vec![0.2, 0.8],
x0: 0.5,
};
assert!(!refutes(model(), WitnessRule::SolverAcceptance));
assert!(!refutes(model(), WitnessRule::DeclaredRowRelative));
}
#[test]
fn homogeneous_row_keeps_the_absolute_floor_under_the_strict_rule() {
let noisy = || WitnessProbeRows {
coeff: vec![1.0],
offset: vec![-0.5 + 1e-16],
g_l: vec![0.0],
g_u: vec![0.0],
x0: 0.5,
};
assert!(refutes(noisy(), WitnessRule::SolverAcceptance));
assert!(
refutes(noisy(), WitnessRule::DeclaredRowRelative),
"a homogeneous row has nothing to be relative to; float noise on it \
must not be promoted to a violation"
);
let violated = || WitnessProbeRows {
coeff: vec![1.0],
offset: vec![0.6],
g_l: vec![0.0],
g_u: vec![0.0],
x0: 0.5,
};
assert!(!refutes(violated(), WitnessRule::SolverAcceptance));
assert!(!refutes(violated(), WitnessRule::DeclaredRowRelative));
}
#[test]
fn strict_rule_still_refutes_at_extreme_row_magnitude() {
let model = || WitnessProbeRows {
coeff: vec![2e30],
offset: vec![1e5],
g_l: vec![1e30],
g_u: vec![1e30],
x0: 0.5,
};
assert!(refutes(model(), WitnessRule::SolverAcceptance));
assert!(refutes(model(), WitnessRule::DeclaredRowRelative));
}
#[test]
fn probing_without_a_solve_certifies_where_the_default_wrapper_withholds() {
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
auxiliary: false,
..PresolveOptions::defaults()
};
let model = || WitnessProbeRows {
coeff: vec![1e-12, 1e-12],
offset: vec![0.0, 0.0],
g_l: vec![0.2e-12, 0.8e-12],
g_u: vec![0.2e-12, 0.8e-12],
x0: 0.5,
};
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model()));
let mut solved_through = PresolveTnlp::new(inner, opts);
solved_through.get_nlp_info().expect("init ok");
assert!(
solved_through.tighten_report().infeasible,
"bound propagation sees the contradiction at every scale"
);
assert_eq!(
solved_through.certified_infeasible(),
None,
"#380: a wrapper that will be solved through must not claim a proof \
the solver's own acceptance test would contradict"
);
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(model()));
let mut probe = PresolveTnlp::new(inner, opts).probing_without_a_solve();
probe.get_nlp_info().expect("init ok");
assert_eq!(
probe.certified_infeasible(),
Some(InfeasibilityProof::BoundPropagation),
"gh#391: with no solve to contradict, the scale-free crossing is a \
proof at every row scale"
);
}
}