#![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;
mod dual_recovery;
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;
mod live_lift;
pub mod matching;
pub mod options;
pub mod reduction_frame;
pub mod redundant;
pub mod trivial_elim;
pub mod warm;
mod warm_echo;
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;
pub use warm::{
PresolveFingerprint, PresolveMap, ProjectedWarm, WarmPoint, WarmProjectionReport, clamp_seed,
compute_fingerprint, project_warm_point, project_warm_point_full,
};
#[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,
project_seed: bool,
witness_rule: WitnessRule,
state: Option<PresolveState>,
finalized_full_solution: Option<(Vec<Number>, Vec<Number>)>,
bound_multipliers_handed_to_rows: Vec<(usize, bool)>,
bound_multiplier_overrides: Vec<(usize, Number, Number)>,
duals_rewritten: bool,
derived_bound_recovery: Option<dual_recovery::Outcome>,
live_record: live_lift::EvalRecord,
served_warm_start: Option<warm_echo::ServedWarmStart>,
starting_point_projection_report: WarmProjectionReport,
}
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,
singleton_bounds: Vec<SingletonBound>,
decl_x_l: Vec<Number>,
decl_x_u: Vec<Number>,
decl_g_l: Vec<Number>,
decl_g_u: Vec<Number>,
phase2_dropped_rows: Vec<(usize, Vec<(usize, Number)>)>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct SingletonBound {
row: usize,
var: usize,
coeff: Number,
upper: bool,
}
fn find_singleton_bounds(
linear_row_map: &[Option<LinearRow>],
kept_before: &[bool],
kept_after: &[bool],
x_l: &[Number],
x_u: &[Number],
decl_l: &[Number],
decl_u: &[Number],
) -> Vec<SingletonBound> {
let same = |a: Number, b: Number| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
let mut out = Vec::new();
let mut claimed: std::collections::BTreeSet<(usize, bool)> = Default::default();
for (row, lr) in linear_row_map.iter().enumerate() {
let Some(lr) = lr else { continue };
if !kept_before.get(row).copied().unwrap_or(false)
|| kept_after.get(row).copied().unwrap_or(true)
{
continue;
}
let mut nz = lr.entries.iter().filter(|(_, a)| *a != 0.0);
let (Some(&(j, a)), None) = (nz.next(), nz.next()) else {
continue;
};
let j = j as usize;
if j >= x_l.len() || !a.is_finite() {
continue;
}
let (imp_l, imp_u) = if a > 0.0 {
(
lower_bound_present(lr.lo).then(|| lr.lo / a),
upper_bound_present(lr.hi).then(|| lr.hi / a),
)
} else {
(
upper_bound_present(lr.hi).then(|| lr.hi / a),
lower_bound_present(lr.lo).then(|| lr.lo / a),
)
};
if let Some(b) = imp_l
&& lower_bound_present(x_l[j])
&& same(x_l[j], b)
&& (!lower_bound_present(decl_l[j]) || x_l[j] > decl_l[j])
&& claimed.insert((j, false))
{
out.push(SingletonBound {
row,
var: j,
coeff: a,
upper: false,
});
}
if let Some(b) = imp_u
&& upper_bound_present(x_u[j])
&& same(x_u[j], b)
&& (!upper_bound_present(decl_u[j]) || x_u[j] < decl_u[j])
&& claimed.insert((j, true))
{
out.push(SingletonBound {
row,
var: j,
coeff: a,
upper: true,
});
}
}
out
}
impl PresolveTnlp {
pub fn new(inner: Rc<RefCell<dyn TNLP>>, opts: PresolveOptions) -> Self {
Self {
inner,
expr_provider: None,
opts,
project_seed: false,
witness_rule: WitnessRule::default(),
state: None,
finalized_full_solution: None,
bound_multipliers_handed_to_rows: Vec::new(),
bound_multiplier_overrides: Vec::new(),
duals_rewritten: false,
derived_bound_recovery: None,
served_warm_start: None,
live_record: live_lift::EvalRecord::default(),
starting_point_projection_report: WarmProjectionReport::default(),
}
}
pub fn probing_without_a_solve(mut self) -> Self {
self.witness_rule = WitnessRule::DeclaredRowRelative;
self
}
pub fn set_project_seed(&mut self, project_seed: bool) {
self.project_seed = project_seed;
}
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,
project_seed: false,
witness_rule: WitnessRule::default(),
state: None,
finalized_full_solution: None,
bound_multipliers_handed_to_rows: Vec::new(),
bound_multiplier_overrides: Vec::new(),
duals_rewritten: false,
derived_bound_recovery: None,
served_warm_start: None,
live_record: live_lift::EvalRecord::default(),
starting_point_projection_report: WarmProjectionReport::default(),
}
}
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 bound_multipliers_handed_to_rows(&self) -> &[(usize, bool)] {
&self.bound_multipliers_handed_to_rows
}
pub fn bound_multiplier_overrides(&self) -> &[(usize, Number, Number)] {
&self.bound_multiplier_overrides
}
pub fn postsolve_rewrote_duals(&self) -> bool {
self.duals_rewritten
}
pub fn derived_bound_dual_recovery(&self) -> Option<(usize, bool)> {
self.derived_bound_recovery
.as_ref()
.filter(|o| o.n_spurious > 0 || o.rolled_back)
.map(|o| (o.n_spurious, !o.rolled_back))
}
fn derived_sides(&self) -> Option<(Vec<bool>, Vec<bool>)> {
let s = self.state.as_ref()?;
let tighter = |p: Number, d: Number, lower: bool| -> bool {
let present = if lower {
lower_bound_present(p)
} else {
upper_bound_present(p)
};
if !present {
return false;
}
let declared = if lower {
lower_bound_present(d)
} else {
upper_bound_present(d)
};
if !declared {
return true;
}
let gap = if lower { p - d } else { d - p };
gap > 1e-9 * d.abs().max(1.0)
};
let n = s.bounds.x_l.len();
let dl = (0..n)
.map(|j| tighter(s.bounds.x_l[j], s.decl_x_l[j], true))
.collect();
let du = (0..n)
.map(|j| tighter(s.bounds.x_u[j], s.decl_x_u[j], false))
.collect();
Some((dl, du))
}
fn original_derivatives(
&mut self,
x: &[Number],
) -> Option<(Vec<Number>, Vec<(usize, usize, Number)>)> {
let (n, nnz, one_based) = {
let s = self.state.as_ref()?;
(
s.info_inner.n as usize,
s.info_inner.nnz_jac_g as usize,
matches!(s.info_inner.index_style, IndexStyle::Fortran),
)
};
let mut grad_f = vec![0.0; n];
if !self.inner.borrow_mut().eval_grad_f(x, true, &mut grad_f) {
return None;
}
let mut irow = vec![0 as Index; nnz];
let mut jcol = vec![0 as Index; nnz];
let mut vals = vec![0.0; nnz];
if nnz > 0 {
let mut inner = self.inner.borrow_mut();
if !inner.eval_jac_g(
None,
false,
SparsityRequest::Structure {
irow: &mut irow,
jcol: &mut jcol,
},
) || !inner.eval_jac_g(
Some(x),
false,
SparsityRequest::Values { values: &mut vals },
) {
return None;
}
}
let off: Index = if one_based { 1 } else { 0 };
let jac = (0..nnz)
.filter_map(|k| {
let i = irow[k] - off;
let j = jcol[k] - off;
(i >= 0 && j >= 0).then_some((i as usize, j as usize, vals[k]))
})
.collect();
Some((grad_f, jac))
}
fn recover_derived_bound_duals(
&mut self,
x: &[Number],
g: &[Number],
lambda: &mut [Number],
z_l: &mut [Number],
z_u: &mut [Number],
) -> Option<dual_recovery::Outcome> {
let (derived_l, derived_u) = self.derived_sides()?;
let n = x.len();
if z_l.len() != n || z_u.len() != n || derived_l.len() != n {
return None;
}
let touched = (0..n).any(|j| {
(derived_l[j] && z_l[j].is_finite() && z_l[j] != 0.0)
|| (derived_u[j] && z_u[j].is_finite() && z_u[j] != 0.0)
});
if !touched {
return None;
}
let (grad_f, jac) = self.original_derivatives(x)?;
let s = self.state.as_ref()?;
let view = dual_recovery::KktView {
x,
g,
grad_f: &grad_f,
jac: &jac,
x_l: &s.decl_x_l,
x_u: &s.decl_x_u,
g_l: &s.decl_g_l,
g_u: &s.decl_g_u,
derived_l: &derived_l,
derived_u: &derived_u,
};
let out = dual_recovery::reattribute(&view, lambda, z_l, z_u);
if out.rolled_back {
tracing::warn!(
target: "pounce_presolve",
"presolve postsolve: multipliers on derived bounds could not be \
re-attributed to the original problem (component past the dense cap \
or residual not reduced); the reported duals keep the reduced \
problem's attribution"
);
} else if out.n_spurious > 0 {
tracing::debug!(
target: "pounce_presolve",
n_spurious = out.n_spurious,
n_unknowns = out.n_unknowns,
residual_before = out.residual_before,
residual_after = out.residual_after,
"presolve postsolve: re-attributed derived-bound multipliers"
);
}
Some(out)
}
fn echoed_warm_start(
&self,
sol: &Solution<'_>,
) -> Option<(Vec<Number>, Vec<Number>, Vec<Number>)> {
let w = self.served_warm_start.as_ref()?;
if !w.echoed_by(sol.z_l, sol.z_u, sol.lambda) {
return None;
}
let s = self.state.as_ref()?;
let n = w.z_l_in.len();
if w.z_u_in.len() != n || s.decl_x_l.len() != n || s.bounds.x_l.len() != n {
return None;
}
let side = |z_in: &[Number], z_out: &[Number], got: &[Number], lower: bool| {
(0..n)
.map(|j| {
let mut z = warm_echo::as_reported_z(z_in[j]);
let declared = if lower {
lower_bound_present(s.decl_x_l[j])
} else {
upper_bound_present(s.decl_x_u[j])
};
let pinned =
s.bounds.x_l[j] == s.bounds.x_u[j] && s.decl_x_l[j] != s.decl_x_u[j];
if !warm_echo::is_seed(z_out[j]) && declared && !pinned && got[j].is_finite() {
z += got[j];
}
z
})
.collect::<Vec<_>>()
};
Some((
w.lambda_in
.iter()
.map(|&l| warm_echo::as_reported_lambda(l))
.collect(),
side(&w.z_l_in, &w.z_l_out, sol.z_l, true),
side(&w.z_u_in, &w.z_u_out, sol.z_u, false),
))
}
#[allow(clippy::too_many_arguments)]
fn forward_finalized(
&mut self,
sol: Solution<'_>,
g_full: &[Number],
lambda_full: &[Number],
lambda_lifted: &[Number],
z_l_full: &[Number],
z_u_full: &[Number],
ip_data: &IpoptData,
ip_cq: &IpoptCq,
) {
self.bound_multiplier_overrides = (0..z_l_full.len().min(sol.z_l.len()))
.filter(|&j| {
z_l_full[j].to_bits() != sol.z_l[j].to_bits()
|| z_u_full.get(j).map(|z| z.to_bits()) != sol.z_u.get(j).map(|z| z.to_bits())
})
.map(|j| (j, z_l_full[j], z_u_full.get(j).copied().unwrap_or(0.0)))
.collect();
self.duals_rewritten = !self.bound_multiplier_overrides.is_empty()
|| lambda_full
.iter()
.zip(lambda_lifted)
.any(|(a, b)| a.to_bits() != b.to_bits());
self.finalized_full_solution = Some((sol.x.to_vec(), lambda_full.to_vec()));
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,
);
}
pub fn reset_starting_point_projection_report(&mut self) {
self.starting_point_projection_report = WarmProjectionReport::default();
}
pub fn starting_point_projection_report(&self) -> WarmProjectionReport {
self.starting_point_projection_report
}
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()
}
pub fn transformation(&mut self) -> Option<PresolveMap> {
let s = self.ensure_init()?;
let mut fixed: std::collections::BTreeMap<usize, Number> =
std::collections::BTreeMap::new();
for frame in s.reduction_stack.iter_bottom_up() {
for (k, &i) in frame.fixed_vars.iter().enumerate() {
if let Some(&v) = frame.fixed_values.get(k) {
fixed.insert(i, v);
}
}
}
let (fixed_vars, fixed_values): (Vec<usize>, Vec<Number>) = fixed.into_iter().unzip();
Some(PresolveMap {
n_inner: s.info_inner.n.max(0) as usize,
m_inner: s.info_inner.m.max(0) as usize,
m_outer: s.info_outer.m.max(0) as usize,
rows_kept: s.rows_kept.clone(),
x_l: s.bounds.x_l.clone(),
x_u: s.bounds.x_u.clone(),
fixed_vars,
fixed_values,
})
}
pub fn fingerprint(&mut self) -> Option<PresolveFingerprint> {
crate::warm::compute_fingerprint(&self.inner, &self.opts)
}
pub fn invalidate(&mut self) {
self.state = None;
self.served_warm_start = None;
self.reset_starting_point_projection_report();
}
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;
let kept_before_phase2 = row_kept_inner.clone();
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 singleton_bounds = find_singleton_bounds(
&linear_row_map,
&kept_before_phase2,
&row_kept_inner,
&x_l,
&x_u,
&inner_x_l,
&inner_x_u,
);
let phase2_dropped_rows: Vec<(usize, Vec<(usize, Number)>)> = linear_row_map
.iter()
.enumerate()
.filter(|(i, _)| {
kept_before_phase2.get(*i).copied().unwrap_or(false)
&& !row_kept_inner.get(*i).copied().unwrap_or(true)
&& !singleton_bounds.iter().any(|sb| sb.row == *i)
})
.filter_map(|(i, lr)| {
lr.as_ref().map(|lr| {
(
i,
lr.entries
.iter()
.filter(|(_, a)| *a != 0.0 && a.is_finite())
.map(|&(j, a)| (j as usize, a))
.collect(),
)
})
})
.collect();
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,
singleton_bounds,
decl_x_l: inner_x_l,
decl_x_u: inner_x_u,
decl_g_l: g_l_inner,
decl_g_u: g_u_inner,
phase2_dropped_rows,
});
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 {
self.served_warm_start = None;
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 = sp.z_l.to_vec();
let mut z_u_full = sp.z_u.to_vec();
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;
}
let handed = (sp.init_z || sp.init_lambda)
.then(|| (z_l_full.clone(), z_u_full.clone(), lambda_full.clone()));
let s = self.state.as_ref().expect("inited");
if sp.init_z && sp.init_lambda {
for sb in &s.singleton_bounds {
let Some(&lam) = lambda_full.get(sb.row) else {
continue;
};
let c = sb.coeff * lam;
if !c.is_finite() {
continue;
}
let (side, z) = if sb.upper {
(&mut z_u_full, c)
} else {
(&mut z_l_full, -c)
};
if z > 0.0
&& let Some(dst) = side.get_mut(sb.var)
{
*dst = if dst.is_finite() { *dst + z } else { z };
}
}
let n = z_l_full.len();
let mut net = vec![0.0; n];
for (row, entries) in &s.phase2_dropped_rows {
let Some(&lam) = lambda_full.get(*row) else {
continue;
};
if !lam.is_finite() || lam == 0.0 {
continue;
}
for &(j, a) in entries {
if let Some(v) = net.get_mut(j) {
*v += a * lam;
}
}
}
for (j, &c) in net.iter().enumerate() {
if !c.is_finite() || c == 0.0 {
continue;
}
let (side, z, present) = if c > 0.0 {
(&mut z_u_full, c, upper_bound_present(s.bounds.x_u[j]))
} else {
(&mut z_l_full, -c, lower_bound_present(s.bounds.x_l[j]))
};
if present && let Some(dst) = side.get_mut(j) {
*dst = if dst.is_finite() { *dst + z } else { z };
}
}
}
sp.z_l.copy_from_slice(&z_l_full);
sp.z_u.copy_from_slice(&z_u_full);
let mut x_fixed_overridden_count = None;
let mut x_clamped_count = None;
if self.project_seed && sp.init_x {
let mut n_fixed = 0;
for frame in s.reduction_stack.iter_bottom_up() {
for (k, &i) in frame.fixed_vars.iter().enumerate() {
if let (Some(dst), Some(&v)) = (sp.x.get_mut(i), frame.fixed_values.get(k)) {
if *dst != v {
n_fixed += 1;
}
*dst = v;
}
}
}
let mut n_clamped = 0;
for (i, v) in sp.x.iter_mut().enumerate() {
if let (Some(&lo), Some(&hi)) = (s.bounds.x_l.get(i), s.bounds.x_u.get(i))
&& let Some(c) = crate::warm::clamp_seed(*v, lo, hi)
{
*v = c;
n_clamped += 1;
}
}
x_fixed_overridden_count = Some(n_fixed);
x_clamped_count = Some(n_clamped);
}
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];
}
let n_dropped_rows = m_in.saturating_sub(s.rows_kept.len());
let dropped_dual_l1 = sp.init_lambda.then(|| {
let mut kept = vec![false; m_in];
for &inner in &s.rows_kept {
if inner < kept.len() {
kept[inner] = true;
}
}
lambda_full
.iter()
.enumerate()
.filter(|(i, _)| !kept[*i])
.map(|(_, value)| value.abs())
.sum()
});
self.starting_point_projection_report.n_dropped_rows = n_dropped_rows;
if let Some(value) = dropped_dual_l1 {
self.starting_point_projection_report.dropped_dual_l1 = value;
}
if let Some(value) = x_fixed_overridden_count {
self.starting_point_projection_report
.x_fixed_overridden_count = value;
}
if let Some(value) = x_clamped_count {
self.starting_point_projection_report.x_clamped_count = value;
}
let served = handed.map(|(z_l_in, z_u_in, lambda_in)| warm_echo::ServedWarmStart {
z_l_in,
z_u_in,
lambda_in,
z_l_out: warm_echo::served_bound_multipliers(
sp.init_z,
sp.z_l,
&s.bounds.x_l,
&s.bounds.x_u,
),
z_u_out: warm_echo::served_bound_multipliers(
sp.init_z,
sp.z_u,
&s.bounds.x_l,
&s.bounds.x_u,
),
lambda_out: warm_echo::served_row_multipliers(sp.init_lambda, sp.lambda),
});
self.served_warm_start = served;
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 {
let ok = self.inner.borrow_mut().eval_grad_f(x, new_x, grad_f);
if ok && self.live_record.enabled(&self.inner) {
self.live_record.grad_f(x, grad_f);
}
ok
}
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;
}
if self.live_record.enabled(&self.inner) {
self.live_record.g(x, &s.scratch_g);
}
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;
}
if let Some(x) = x
&& self.live_record.enabled(&self.inner)
{
self.live_record.jac(x, &s.scratch_jac);
}
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),
)
};
if let Some((lambda_e, z_l_e, z_u_e)) = self.echoed_warm_start(&sol) {
tracing::debug!(
target: "pounce_presolve",
"presolve postsolve: the solver returned the served warm start unchanged; \
reporting the supplied duals"
);
self.bound_multipliers_handed_to_rows.clear();
self.derived_bound_recovery = None;
self.forward_finalized(
sol,
&g_full,
&lambda_e,
&lambda_full,
&z_l_e,
&z_u_e,
ip_data,
ip_cq,
);
return;
}
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;
}
}
}
}
}
}
let lambda_lifted = lambda_full.clone();
self.bound_multipliers_handed_to_rows.clear();
{
let s = self.state.as_ref().expect("inited");
for sb in &s.singleton_bounds {
let side = if sb.upper {
&mut z_u_full
} else {
&mut z_l_full
};
let (Some(z), Some(lam)) = (side.get_mut(sb.var), lambda_full.get_mut(sb.row))
else {
continue;
};
if !z.is_finite() || *z == 0.0 {
continue;
}
let signed = if sb.upper { *z } else { -*z };
*lam += signed / sb.coeff;
*z = 0.0;
self.bound_multipliers_handed_to_rows
.push((sb.var, sb.upper));
}
}
self.derived_bound_recovery = self.recover_derived_bound_duals(
sol.x,
&g_full,
&mut lambda_full,
&mut z_l_full,
&mut z_u_full,
);
self.forward_finalized(
sol,
&g_full,
&lambda_full,
&lambda_lifted,
&z_l_full,
&z_u_full,
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 wants_intermediate_callback(&self) -> bool {
self.inner.borrow().wants_intermediate_callback()
}
fn wants_current_iterate(&self) -> bool {
self.inner.borrow().wants_current_iterate()
}
fn intermediate_callback(
&mut self,
stats: IterStats,
ip_data: &IpoptData,
ip_cq: &IpoptCq,
) -> bool {
let lifted = match self.get_nlp_info() {
Some(info) => {
let saved = (
self.finalized_full_solution.take(),
std::mem::take(&mut self.bound_multipliers_handed_to_rows),
std::mem::take(&mut self.bound_multiplier_overrides),
self.duals_rewritten,
self.derived_bound_recovery.take(),
);
let cols: Vec<usize> = (0..info.n as usize).collect();
let rows: Vec<usize> = self
.state
.as_ref()
.map(|s| s.rows_kept.clone())
.unwrap_or_else(|| (0..info.m as usize).collect());
let lifted = live_lift::lift(
self,
|s| &mut s.inner,
(info.n as usize, info.m as usize),
live_lift::LiftMaps {
cols: &cols,
rows: &rows,
declared_x_l: &[],
declared_x_u: &[],
record: self.live_record.clone(),
},
);
(
self.finalized_full_solution,
self.bound_multipliers_handed_to_rows,
self.bound_multiplier_overrides,
self.duals_rewritten,
self.derived_bound_recovery,
) = saved;
lifted
}
None => None,
};
let _lifted = lifted.map(pounce_nlp::live_iterate::UserIterateGuard::install);
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);
wrapped.set_project_seed(true);
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);
wrapped.set_project_seed(true);
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 (mut x0, mut z_l0, mut z_u0) = (vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]);
let mut lambda0 = Vec::new();
wrapped.reset_starting_point_projection_report();
assert!(wrapped.get_starting_point(StartingPoint {
init_x: true,
x: &mut x0,
init_z: false,
z_l: &mut z_l0,
z_u: &mut z_u0,
init_lambda: true,
lambda: &mut lambda0,
}));
assert_eq!(x0, vec![2.0, 1.0], "wrapper serves aux-fixed values");
let start_report = wrapped.starting_point_projection_report();
assert_eq!(
start_report.x_fixed_overridden_count, 2,
"report = {start_report:?}"
);
assert_eq!(start_report.x_clamped_count, 0, "report = {start_report:?}");
assert_eq!(start_report.n_dropped_rows, 2, "report = {start_report:?}");
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 TightenSeed {
x0: Vec<Number>,
}
impl TNLP for TightenSeed {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 1,
nnz_jac_g: 2,
nnz_h_lag: 0,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l.copy_from_slice(&[0.0, 0.0]);
b.x_u.copy_from_slice(&[5.0, 5.0]);
b.g_l[0] = 3.0;
b.g_u[0] = 3.0;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x.copy_from_slice(&self.x0);
}
true
}
fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
Some(x[0] * x[0])
}
fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 2.0 * x[0];
g[1] = 0.0;
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 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]);
jcol.copy_from_slice(&[0, 1]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[1.0, 1.0]);
}
}
true
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
types.fill(Linearity::Linear);
true
}
fn finalize_solution(&mut self, _s: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
#[test]
fn served_warm_primal_records_its_projection() {
let inner: Rc<RefCell<dyn TNLP>> =
Rc::new(RefCell::new(TightenSeed { x0: vec![5.0, 5.0] }));
let opts = PresolveOptions {
enabled: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::clone(&inner), opts);
wrapped.set_project_seed(true);
let (mut x_l, mut x_u) = (vec![0.0; 2], vec![0.0; 2]);
let (mut g_l, mut g_u) = (vec![0.0; 1], vec![0.0; 1]);
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_u, vec![3.0, 3.0], "row x + y = 3 tightens x_u to 3");
let (mut x, mut z_l, mut z_u) = (vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]);
let mut lambda = vec![0.0; 1];
assert!(wrapped.get_starting_point(StartingPoint {
init_x: true,
x: &mut x,
init_z: false,
z_l: &mut z_l,
z_u: &mut z_u,
init_lambda: false,
lambda: &mut lambda,
}));
assert_eq!(
x,
vec![3.0, 3.0],
"served seed must be clamped into the tightened box"
);
let report = wrapped.starting_point_projection_report();
assert_eq!(report.x_clamped_count, 2, "report = {report:?}");
assert_eq!(report.x_fixed_overridden_count, 0, "report = {report:?}");
}
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 SingletonLowerRow {
a: Number,
decl_l: Number,
}
impl TNLP for SingletonLowerRow {
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] = self.decl_l;
b.x_u[0] = 10.0;
if self.a > 0.0 {
b.g_l[0] = 2.0 * self.a;
b.g_u[0] = 1e19;
} else {
b.g_l[0] = -1e19;
b.g_u[0] = 2.0 * self.a;
}
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x[0] = 5.0;
}
if sp.init_lambda {
sp.lambda[0] = -1.0 / self.a;
}
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] = self.a * 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] = self.a;
}
}
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) {}
}
fn finalize_singleton(a: Number, decl_l: Number) -> (Number, Vec<(usize, bool)>) {
let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(SingletonLowerRow { a, decl_l }));
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
redundant_constraint_removal: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(inner, opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(wrapped.n_dropped_rows(), 1, "a={a}: the row is dropped");
assert_eq!(info.m, 0);
let b = wrapped.cached_bounds().expect("inited");
assert!((b.x_l[0] - 2.0).abs() < 1e-12, "a={a}: x_l = {}", b.x_l[0]);
let (x, z_l, z_u) = ([2.0], [1.0], [0.0]);
let (g, lambda): ([Number; 0], [Number; 0]) = ([], []);
wrapped.finalize_solution(
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,
},
&IpoptData::default(),
&IpoptCq::default(),
);
let (_, lamf) = wrapped.finalized_full_solution().expect("finalized");
assert_eq!(lamf.len(), 1, "full-space lambda regains the original row");
(lamf[0], wrapped.bound_multipliers_handed_to_rows().to_vec())
}
#[test]
fn dropped_singleton_row_takes_back_its_bound_dual() {
for a in [1.0, 4.0, -1.0, -0.5] {
let (lam, handed) = finalize_singleton(a, 0.0);
assert!(
(lam - (-1.0 / a)).abs() < 1e-12,
"a={a}: dropped-row λ = {lam}, expected {}",
-1.0 / a
);
assert_eq!(handed, vec![(0, false)], "a={a}: z_l[0] was handed back");
let stat = 1.0 + a * lam;
assert!(
stat.abs() < 1e-12,
"a={a}: KKT stationarity residual {stat}"
);
}
}
#[test]
fn a_declared_bound_tied_with_the_row_keeps_its_dual() {
let (lam, handed) = finalize_singleton(1.0, 2.0);
assert_eq!(lam, 0.0, "the row is redundant against the declared bound");
assert!(handed.is_empty(), "{handed:?}");
}
#[test]
fn a_warm_row_multiplier_seeds_the_bound_presolve_made_from_it() {
for a in [1.0, -0.5] {
let inner: Rc<RefCell<dyn TNLP>> =
Rc::new(RefCell::new(SingletonLowerRow { a, decl_l: 0.0 }));
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
redundant_constraint_removal: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(inner, opts);
let _ = wrapped.get_nlp_info().expect("init ok");
let (mut x, mut z_l, mut z_u) = ([0.0], [Number::NAN], [Number::NAN]);
let mut lambda: [Number; 0] = [];
assert!(wrapped.get_starting_point(StartingPoint {
init_x: true,
x: &mut x,
init_z: true,
z_l: &mut z_l,
z_u: &mut z_u,
init_lambda: true,
lambda: &mut lambda,
}));
assert!((z_l[0] - 1.0).abs() < 1e-12, "a={a}: z_l = {}", z_l[0]);
assert!(z_u[0].is_nan(), "a={a}: the upper side stays unseeded");
}
}
struct DerivedChain;
impl TNLP for DerivedChain {
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.fill(-1e19);
b.x_u.fill(1e19);
b.g_l.copy_from_slice(&[0.5, -1e19]);
b.g_u.copy_from_slice(&[0.5, 2.0]);
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
if sp.init_x {
sp.x.copy_from_slice(&[0.5, 1.5]);
}
if sp.init_lambda {
sp.lambda.copy_from_slice(&[-6.0, 3.0]);
}
true
}
fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
Some((x[0] + 1.0).powi(2) + (x[1] - 3.0).powi(2))
}
fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[0] = 2.0 * (x[0] + 1.0);
g[1] = 2.0 * (x[1] - 3.0);
true
}
fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
g[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 } => values.copy_from_slice(&[1.0, 1.0, 1.0]),
}
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 derived_chain() -> PresolveTnlp {
let opts = PresolveOptions {
enabled: true,
bound_tightening: true,
redundant_constraint_removal: true,
..PresolveOptions::defaults()
};
let mut wrapped = PresolveTnlp::new(Rc::new(RefCell::new(DerivedChain)), opts);
let info = wrapped.get_nlp_info().expect("init ok");
assert_eq!(info.m, 0, "both rows are dropped");
let b = wrapped.cached_bounds().expect("inited");
assert!(
(b.x_u[1] - 1.5).abs() < 1e-12,
"x1 ≤ 1.5 derived: {}",
b.x_u[1]
);
wrapped
}
#[test]
fn a_derived_bound_multiplier_is_recovered_onto_its_rows() {
let mut wrapped = derived_chain();
let (x, z_l, z_u) = ([0.5, 1.5], [3.0, 0.0], [0.0, 3.0]);
let (g, lambda): ([Number; 0], [Number; 0]) = ([], []);
wrapped.finalize_solution(
Solution {
status: pounce_nlp::alg_types::SolverReturn::Success,
x: &x,
z_l: &z_l,
z_u: &z_u,
g: &g,
lambda: &lambda,
obj_value: 4.5,
},
&IpoptData::default(),
&IpoptCq::default(),
);
let (_, lam) = wrapped.finalized_full_solution().expect("finalized");
assert!(
(lam[0] + 6.0).abs() < 1e-9 && (lam[1] - 3.0).abs() < 1e-9,
"λ = {lam:?}"
);
assert_eq!(wrapped.derived_bound_dual_recovery(), Some((1, true)));
let ov = wrapped.bound_multiplier_overrides().to_vec();
assert!(
ov.iter().all(|&(_, l, u)| l.abs() < 1e-9 && u.abs() < 1e-9) && ov.len() == 2,
"{ov:?}"
);
assert!(wrapped.postsolve_rewrote_duals());
}
#[test]
fn a_dropped_rows_warm_multiplier_seeds_the_derived_bound() {
let mut wrapped = derived_chain();
let (mut x, mut z_l, mut z_u) = ([0.0; 2], [Number::NAN; 2], [Number::NAN; 2]);
let mut lambda: [Number; 0] = [];
assert!(wrapped.get_starting_point(StartingPoint {
init_x: true,
x: &mut x,
init_z: true,
z_l: &mut z_l,
z_u: &mut z_u,
init_lambda: true,
lambda: &mut lambda,
}));
assert!((z_u[1] - 3.0).abs() < 1e-12, "z_U(x1) = {}", z_u[1]);
assert!(z_l[1].is_nan(), "x1's lower side stays unseeded");
assert!(
(z_l[0] - 6.0).abs() < 1e-12 && (z_u[0] - 3.0).abs() < 1e-12,
"{z_l:?} {z_u:?}"
);
}
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"
);
}
}