use crate::application::IpoptApplication;
use pounce_common::types::{Index, Number};
use pounce_linsol::{EMatrixFormat, ESymSolverStatus, SparseSymLinearSolverInterface};
use pounce_nlp::alg_types::SolverReturn;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::solve_statistics::SolveStatistics;
use pounce_nlp::tnlp::{
BoundsInfo, IpoptCq, IpoptData, IterStats, Linearity, MetaData, NlpInfo, ScalingRequest,
Solution, SparsityRequest, StartingPoint, TNLP,
};
use rayon::prelude::*;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread::{self, ThreadId};
#[derive(Debug, Clone)]
pub struct NlpBatchSolution {
pub solver_status: SolverReturn,
pub x: Vec<Number>,
pub z_l: Vec<Number>,
pub z_u: Vec<Number>,
pub g: Vec<Number>,
pub lambda: Vec<Number>,
pub obj: Number,
}
#[derive(Debug, Clone)]
pub struct NlpBatchResult {
pub status: ApplicationReturnStatus,
pub solution: Option<NlpBatchSolution>,
pub stats: SolveStatistics,
}
struct CaptureTnlp<T: TNLP> {
inner: T,
captured: Option<NlpBatchSolution>,
}
impl<T: TNLP> TNLP for CaptureTnlp<T> {
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, grad_f: &mut [Number]) -> bool {
self.inner.eval_grad_f(x, new_x, grad_f)
}
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 eval_h(
&mut self,
x: Option<&[Number]>,
new_x: bool,
obj_factor: Number,
lambda: Option<&[Number]>,
new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
self.inner
.eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
}
fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
self.captured = Some(NlpBatchSolution {
solver_status: sol.status,
x: sol.x.to_vec(),
z_l: sol.z_l.to_vec(),
z_u: sol.z_u.to_vec(),
g: sol.g.to_vec(),
lambda: sol.lambda.to_vec(),
obj: sol.obj_value,
});
self.inner.finalize_solution(sol, ip_data, ip_cq);
}
fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
self.inner.get_var_con_metadata(var, con)
}
fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
self.inner.get_scaling_parameters(req)
}
fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_variables_linearity(types)
}
fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_objective_variables_linearity(types)
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_constraints_linearity(types)
}
fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
self.inner.get_number_of_nonlinear_variables()
}
fn get_list_of_nonlinear_variables(
&mut self,
pos_nonlin_vars: &mut [pounce_common::types::Index],
) -> bool {
self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
}
fn intermediate_callback(
&mut self,
stats: IterStats,
ip_data: &IpoptData,
ip_cq: &IpoptCq,
) -> bool {
self.inner.intermediate_callback(stats, ip_data, ip_cq)
}
fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
self.inner.finalize_metadata(var, con)
}
}
pub fn install_serial_feral_backend(app: &mut IpoptApplication) {
let mut cfg = crate::application::feral_config_from_options(app.options());
cfg.parallel = Some(false);
app.set_linear_backend_factory(Box::new(move |_choice| {
Box::new(pounce_feral::FeralSolverInterface::with_config(cfg.clone()))
}));
}
pub struct FeralBackendPool {
cfg: pounce_feral::FeralConfig,
slots: Mutex<HashMap<ThreadId, pounce_feral::FeralSolverInterface>>,
}
impl FeralBackendPool {
pub fn serial(mut cfg: pounce_feral::FeralConfig) -> Arc<Self> {
cfg.parallel = Some(false);
Arc::new(Self {
cfg,
slots: Mutex::new(HashMap::new()),
})
}
fn acquire(self: &Arc<Self>) -> PooledFeralBackend {
let recycled = self
.slots
.lock()
.ok()
.and_then(|mut s| s.remove(&thread::current().id()));
let inner = recycled
.unwrap_or_else(|| pounce_feral::FeralSolverInterface::with_config(self.cfg.clone()));
PooledFeralBackend {
inner: Some(inner),
pool: Arc::clone(self),
}
}
}
impl std::fmt::Debug for FeralBackendPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parked = self.slots.lock().map(|s| s.len()).unwrap_or(0);
f.debug_struct("FeralBackendPool")
.field("parked", &parked)
.finish_non_exhaustive()
}
}
struct PooledFeralBackend {
inner: Option<pounce_feral::FeralSolverInterface>,
pool: Arc<FeralBackendPool>,
}
impl PooledFeralBackend {
fn get(&mut self) -> &mut pounce_feral::FeralSolverInterface {
#[allow(clippy::expect_used)]
self.inner.as_mut().expect("pooled backend already taken")
}
}
impl Drop for PooledFeralBackend {
fn drop(&mut self) {
if let (Some(solver), Ok(mut slots)) = (self.inner.take(), self.pool.slots.lock()) {
slots.entry(thread::current().id()).or_insert(solver);
}
}
}
impl SparseSymLinearSolverInterface for PooledFeralBackend {
fn initialize_structure(
&mut self,
dim: Index,
nonzeros: Index,
ia: &[Index],
ja: &[Index],
) -> ESymSolverStatus {
self.get().initialize_structure(dim, nonzeros, ia, ja)
}
fn values_array_mut(&mut self) -> &mut [Number] {
self.get().values_array_mut()
}
fn multi_solve(
&mut self,
new_matrix: bool,
ia: &[Index],
ja: &[Index],
nrhs: Index,
rhs_vals: &mut [Number],
check_neg_evals: bool,
number_of_neg_evals: Index,
) -> ESymSolverStatus {
self.get().multi_solve(
new_matrix,
ia,
ja,
nrhs,
rhs_vals,
check_neg_evals,
number_of_neg_evals,
)
}
fn number_of_neg_evals(&self) -> Index {
match &self.inner {
Some(s) => s.number_of_neg_evals(),
None => 0,
}
}
fn increase_quality(&mut self) -> bool {
self.get().increase_quality()
}
fn provides_inertia(&self) -> bool {
self.inner.as_ref().is_some_and(|s| s.provides_inertia())
}
fn matrix_format(&self) -> EMatrixFormat {
match &self.inner {
Some(s) => s.matrix_format(),
None => EMatrixFormat::TripletFormat,
}
}
fn provides_degeneracy_detection(&self) -> bool {
self.inner
.as_ref()
.is_some_and(|s| s.provides_degeneracy_detection())
}
fn determine_dependent_rows(
&mut self,
n_rows: Index,
n_cols: Index,
irn: &[Index],
jcn: &[Index],
vals: &[Number],
c_deps: &mut Vec<Index>,
) -> ESymSolverStatus {
self.get()
.determine_dependent_rows(n_rows, n_cols, irn, jcn, vals, c_deps)
}
fn factor_pattern(&self, want_values: bool) -> Option<pounce_linsol::FactorPattern> {
self.inner
.as_ref()
.and_then(|s| s.factor_pattern(want_values))
}
}
pub fn install_pooled_serial_feral_backend(
app: &mut IpoptApplication,
pool: &Arc<FeralBackendPool>,
) {
let pool = Arc::clone(pool);
app.set_linear_backend_factory(Box::new(move |_choice| Box::new(pool.acquire())));
}
#[derive(Debug, Clone, Default)]
pub struct NlpWarmStart {
pub x: Vec<Number>,
pub lambda: Vec<Number>,
pub z_l: Vec<Number>,
pub z_u: Vec<Number>,
pub mu: Option<Number>,
}
impl From<&NlpBatchSolution> for NlpWarmStart {
fn from(sol: &NlpBatchSolution) -> Self {
Self {
x: sol.x.clone(),
lambda: sol.lambda.clone(),
z_l: sol.z_l.clone(),
z_u: sol.z_u.clone(),
mu: None,
}
}
}
impl From<&NlpBatchResult> for NlpWarmStart {
fn from(r: &NlpBatchResult) -> Self {
match &r.solution {
Some(sol) => Self {
mu: Some(r.stats.final_mu),
..Self::from(sol)
},
None => Self::default(),
}
}
}
const WARM_MU_FLOOR: Number = 1e-9;
fn apply_warm_options(app: &mut IpoptApplication, mu: Option<Number>) {
let _ = app
.options_mut()
.set_string_value("warm_start_init_point", "yes", true, false);
if let Some(mu) = mu {
let user_set_mu = matches!(
app.options().get_numeric_value("mu_init", ""),
Ok((_, true))
);
if !user_set_mu {
let _ =
app.options_mut()
.set_numeric_value("mu_init", mu.max(WARM_MU_FLOOR), true, false);
}
}
}
struct WarmStartTnlp<T: TNLP> {
inner: T,
warm: NlpWarmStart,
}
impl<T: TNLP> TNLP for WarmStartTnlp<T> {
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 {
let dims_ok = self.warm.x.len() == sp.x.len()
&& (!sp.init_lambda || self.warm.lambda.len() == sp.lambda.len())
&& (!sp.init_z
|| (self.warm.z_l.len() == sp.z_l.len() && self.warm.z_u.len() == sp.z_u.len()));
if !dims_ok {
return self.inner.get_starting_point(sp);
}
if sp.init_x {
sp.x.copy_from_slice(&self.warm.x);
}
if sp.init_z {
sp.z_l.copy_from_slice(&self.warm.z_l);
sp.z_u.copy_from_slice(&self.warm.z_u);
}
if sp.init_lambda {
sp.lambda.copy_from_slice(&self.warm.lambda);
}
true
}
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, grad_f: &mut [Number]) -> bool {
self.inner.eval_grad_f(x, new_x, grad_f)
}
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 eval_h(
&mut self,
x: Option<&[Number]>,
new_x: bool,
obj_factor: Number,
lambda: Option<&[Number]>,
new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
self.inner
.eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
}
fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
self.inner.finalize_solution(sol, ip_data, ip_cq)
}
fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
self.inner.get_var_con_metadata(var, con)
}
fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
self.inner.get_scaling_parameters(req)
}
fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_variables_linearity(types)
}
fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_objective_variables_linearity(types)
}
fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
self.inner.get_constraints_linearity(types)
}
fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
self.inner.get_number_of_nonlinear_variables()
}
fn get_list_of_nonlinear_variables(
&mut self,
pos_nonlin_vars: &mut [pounce_common::types::Index],
) -> bool {
self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
}
fn intermediate_callback(
&mut self,
stats: IterStats,
ip_data: &IpoptData,
ip_cq: &IpoptCq,
) -> bool {
self.inner.intermediate_callback(stats, ip_data, ip_cq)
}
fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
self.inner.finalize_metadata(var, con)
}
}
fn solve_nlp_one<T, C>(index: usize, tnlp: T, configure: &mut C) -> NlpBatchResult
where
T: TNLP + 'static,
C: FnMut(usize, &mut IpoptApplication),
{
let mut app = IpoptApplication::new();
configure(index, &mut app);
let cap = Rc::new(RefCell::new(CaptureTnlp {
inner: tnlp,
captured: None,
}));
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let status = app.optimize_tnlp(Rc::clone(&cap) as Rc<RefCell<dyn TNLP>>);
let stats = app.statistics();
let solution = cap.borrow_mut().captured.take();
NlpBatchResult {
status,
solution,
stats,
}
}));
outcome.unwrap_or_else(|_| NlpBatchResult {
status: ApplicationReturnStatus::InternalError,
solution: None,
stats: SolveStatistics::default(),
})
}
pub fn solve_nlp_batch<T, C>(problems: Vec<T>, mut configure: C) -> Vec<NlpBatchResult>
where
T: TNLP + 'static,
C: FnMut(usize, &mut IpoptApplication),
{
problems
.into_iter()
.enumerate()
.map(|(i, t)| solve_nlp_one(i, t, &mut configure))
.collect()
}
pub fn solve_nlp_batch_parallel<T, C>(problems: Vec<T>, configure: C) -> Vec<NlpBatchResult>
where
T: TNLP + Send + 'static,
C: Fn(usize, &mut IpoptApplication) + Sync,
{
problems
.into_par_iter()
.enumerate()
.map(|(i, t)| {
solve_nlp_one(i, t, &mut |i: usize, app: &mut IpoptApplication| {
configure(i, app)
})
})
.collect()
}
pub fn solve_nlp_batch_warm<T, C>(
problems: Vec<T>,
warms: Vec<NlpWarmStart>,
mut configure: C,
) -> Vec<NlpBatchResult>
where
T: TNLP + 'static,
C: FnMut(usize, &mut IpoptApplication),
{
assert_eq!(
warms.len(),
problems.len(),
"warms.len() ({}) must equal problems.len() ({})",
warms.len(),
problems.len()
);
let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
let wrapped: Vec<WarmStartTnlp<T>> = problems
.into_iter()
.zip(warms)
.map(|(inner, warm)| WarmStartTnlp { inner, warm })
.collect();
solve_nlp_batch(wrapped, |i, app: &mut IpoptApplication| {
configure(i, app);
apply_warm_options(app, mus[i]);
})
}
pub fn solve_nlp_batch_parallel_warm<T, C>(
problems: Vec<T>,
warms: Vec<NlpWarmStart>,
configure: C,
) -> Vec<NlpBatchResult>
where
T: TNLP + Send + 'static,
C: Fn(usize, &mut IpoptApplication) + Sync,
{
assert_eq!(
warms.len(),
problems.len(),
"warms.len() ({}) must equal problems.len() ({})",
warms.len(),
problems.len()
);
let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
let wrapped: Vec<WarmStartTnlp<T>> = problems
.into_iter()
.zip(warms)
.map(|(inner, warm)| WarmStartTnlp { inner, warm })
.collect();
solve_nlp_batch_parallel(wrapped, |i, app: &mut IpoptApplication| {
configure(i, app);
apply_warm_options(app, mus[i]);
})
}
#[cfg(test)]
mod tests {
use super::*;
use pounce_nlp::tnlp::IndexStyle;
struct ShiftedQuad {
a: f64,
b: f64,
s: f64,
x_l: [f64; 2],
x_u: [f64; 2],
}
impl ShiftedQuad {
fn new(a: f64, b: f64, s: f64) -> Self {
Self {
a,
b,
s,
x_l: [-1e19; 2],
x_u: [1e19; 2],
}
}
fn expected(&self) -> [f64; 2] {
let t = (self.s - self.a - self.b) / 2.0;
[self.a + t, self.b + t]
}
}
impl TNLP for ShiftedQuad {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: 2,
m: 1,
nnz_jac_g: 2,
nnz_h_lag: 2,
index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l.copy_from_slice(&self.x_l);
b.x_u.copy_from_slice(&self.x_u);
b.g_l[0] = self.s;
b.g_u[0] = self.s;
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
sp.x[0] = 0.0;
sp.x[1] = 0.0;
true
}
fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
Some((x[0] - self.a).powi(2) + (x[1] - self.b).powi(2))
}
fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
grad_f[0] = 2.0 * (x[0] - self.a);
grad_f[1] = 2.0 * (x[1] - self.b);
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 eval_h(
&mut self,
_x: Option<&[Number]>,
_new_x: bool,
obj_factor: Number,
_lambda: Option<&[Number]>,
_new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
irow.copy_from_slice(&[0, 1]);
jcol.copy_from_slice(&[0, 1]);
}
SparsityRequest::Values { values } => {
values.copy_from_slice(&[2.0 * obj_factor, 2.0 * obj_factor]);
}
}
true
}
fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
}
fn configure(_i: usize, app: &mut IpoptApplication) {
let _ = app
.options_mut()
.set_integer_value("print_level", 0, true, false);
install_serial_feral_backend(app);
}
fn batch(k: usize) -> Vec<ShiftedQuad> {
(0..k)
.map(|i| ShiftedQuad::new(1.0 + i as f64, 2.0, 1.0 + (i % 3) as f64))
.collect()
}
struct BoomQuad {
inner: ShiftedQuad,
boom: bool,
}
impl TNLP for BoomQuad {
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> {
if self.boom {
panic!("boom: simulated mid-solve panic in eval_f");
}
self.inner.eval_f(x, new_x)
}
fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
self.inner.eval_grad_f(x, new_x, grad_f)
}
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 eval_h(
&mut self,
x: Option<&[Number]>,
new_x: bool,
obj_factor: Number,
lambda: Option<&[Number]>,
new_lambda: bool,
mode: SparsityRequest<'_>,
) -> bool {
self.inner
.eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
}
fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
self.inner.finalize_solution(sol, d, q)
}
}
#[test]
fn empty_batch_returns_empty() {
let out = solve_nlp_batch_parallel(Vec::<ShiftedQuad>::new(), configure);
assert!(out.is_empty());
}
#[test]
fn single_element_batch_solves() {
let probs = batch(1);
let expected = probs[0].expected();
let out = solve_nlp_batch_parallel(probs, configure);
assert_eq!(out.len(), 1);
assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
let sol = out[0].solution.as_ref().expect("solution captured");
assert!((sol.x[0] - expected[0]).abs() < 1e-6);
assert!((sol.x[1] - expected[1]).abs() < 1e-6);
}
#[test]
fn parallel_results_in_input_order_and_match_sequential() {
let k = 8;
let expected: Vec<[f64; 2]> = batch(k).iter().map(|p| p.expected()).collect();
let par = solve_nlp_batch_parallel(batch(k), configure);
let seq = solve_nlp_batch(batch(k), configure);
assert_eq!(par.len(), k);
for i in 0..k {
assert_eq!(
par[i].status,
ApplicationReturnStatus::SolveSucceeded,
"instance {i}"
);
let ps = par[i].solution.as_ref().expect("parallel solution");
let ss = seq[i].solution.as_ref().expect("sequential solution");
assert!(
(ps.x[0] - expected[i][0]).abs() < 1e-6 && (ps.x[1] - expected[i][1]).abs() < 1e-6,
"instance {i}: got {:?}, expected {:?}",
ps.x,
expected[i]
);
assert_eq!(ps.x, ss.x, "instance {i}");
assert_eq!(
par[i].stats.iteration_count, seq[i].stats.iteration_count,
"instance {i}"
);
}
}
#[test]
fn infeasible_instance_mixed_in_does_not_poison_batch() {
let mut probs = batch(3);
probs[1].s = 10.0;
probs[1].x_u = [1.0; 2];
let out = solve_nlp_batch_parallel(probs, configure);
assert_eq!(out.len(), 3);
assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
assert_eq!(out[2].status, ApplicationReturnStatus::SolveSucceeded);
assert_ne!(
out[1].status,
ApplicationReturnStatus::SolveSucceeded,
"infeasible instance must not report success"
);
}
#[test]
fn panicking_instance_does_not_poison_batch() {
let good = batch(3);
let expected: Vec<[f64; 2]> = good.iter().map(|p| p.expected()).collect();
let probs: Vec<BoomQuad> = good
.into_iter()
.enumerate()
.map(|(i, inner)| BoomQuad {
inner,
boom: i == 1,
})
.collect();
let out = solve_nlp_batch_parallel(probs, configure);
assert_eq!(out.len(), 3);
assert_eq!(out[1].status, ApplicationReturnStatus::InternalError);
assert!(
out[1].solution.is_none(),
"a panicked instance carries no captured solution"
);
for i in [0, 2] {
assert_eq!(out[i].status, ApplicationReturnStatus::SolveSucceeded);
let sol = out[i].solution.as_ref().expect("solution");
assert!(
(sol.x[0] - expected[i][0]).abs() < 1e-6
&& (sol.x[1] - expected[i][1]).abs() < 1e-6,
"instance {i}: got {:?}, expected {:?}",
sol.x,
expected[i]
);
}
let probs_seq: Vec<BoomQuad> = batch(3)
.into_iter()
.enumerate()
.map(|(i, inner)| BoomQuad {
inner,
boom: i == 1,
})
.collect();
let seq = solve_nlp_batch(probs_seq, configure);
assert_eq!(seq[1].status, ApplicationReturnStatus::InternalError);
assert_eq!(seq[0].status, ApplicationReturnStatus::SolveSucceeded);
assert_eq!(seq[2].status, ApplicationReturnStatus::SolveSucceeded);
}
#[test]
fn warm_started_batch_chains() {
let k = 4;
let cold = solve_nlp_batch_parallel(batch(k), configure);
let warms: Vec<NlpWarmStart> = cold.iter().map(NlpWarmStart::from).collect();
let perturbed = || -> Vec<ShiftedQuad> {
batch(k)
.into_iter()
.map(|mut p| {
p.s += 0.01;
p
})
.collect()
};
let warm = solve_nlp_batch_parallel_warm(perturbed(), warms, configure);
let cold2 = solve_nlp_batch_parallel(perturbed(), configure);
for i in 0..k {
assert_eq!(warm[i].status, ApplicationReturnStatus::SolveSucceeded);
let expect = perturbed()[i].expected();
let sol = warm[i].solution.as_ref().expect("warm solution");
assert!(
(sol.x[0] - expect[0]).abs() < 1e-5 && (sol.x[1] - expect[1]).abs() < 1e-5,
"instance {i}: warm solve must reach the perturbed optimum"
);
assert!(
warm[i].stats.iteration_count <= cold2[i].stats.iteration_count,
"instance {i}: warm start took {} iters vs cold {}",
warm[i].stats.iteration_count,
cold2[i].stats.iteration_count
);
}
}
#[test]
fn warm_start_dimension_mismatch_falls_back_cold() {
let probs = batch(2);
let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
let warms = vec![NlpWarmStart::default(), NlpWarmStart::default()];
let out = solve_nlp_batch_parallel_warm(probs, warms, configure);
for (i, r) in out.iter().enumerate() {
assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
let sol = r.solution.as_ref().expect("solution");
assert!(
(sol.x[0] - expected[i][0]).abs() < 1e-6
&& (sol.x[1] - expected[i][1]).abs() < 1e-6
);
}
}
#[test]
fn backend_pool_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<FeralBackendPool>();
fn assert_send<T: Send>() {}
assert_send::<pounce_feral::FeralSolverInterface>();
}
#[test]
fn pooled_backends_match_fresh_on_identical_structure() {
let k = 8;
let fresh = solve_nlp_batch_parallel(batch(k), configure);
let pool = FeralBackendPool::serial(pounce_feral::FeralConfig::default());
let pool_for_cfg = Arc::clone(&pool);
let pooled = solve_nlp_batch_parallel(batch(k), move |_i, app| {
let _ = app
.options_mut()
.set_integer_value("print_level", 0, true, false);
install_pooled_serial_feral_backend(app, &pool_for_cfg);
});
assert!(
pool.slots.lock().map(|s| !s.is_empty()).unwrap_or(false),
"at least one worker must have parked its backend"
);
for i in 0..k {
assert_eq!(pooled[i].status, ApplicationReturnStatus::SolveSucceeded);
let pf = fresh[i].solution.as_ref().expect("fresh");
let pp = pooled[i].solution.as_ref().expect("pooled");
for j in 0..2 {
assert!(
(pf.x[j] - pp.x[j]).abs() < 1e-6,
"instance {i} x[{j}]: pooled {} vs fresh {}",
pp.x[j],
pf.x[j]
);
}
}
}
#[test]
fn ragged_iteration_counts_keep_order() {
let probs: Vec<ShiftedQuad> = (0..6)
.map(|i| ShiftedQuad::new(10f64.powi(i - 3), 2.0, 1.0))
.collect();
let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
let out = solve_nlp_batch_parallel(probs, configure);
for (i, r) in out.iter().enumerate() {
assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
let sol = r.solution.as_ref().expect("solution");
assert!(
(sol.x[0] - expected[i][0]).abs() < 1e-5
&& (sol.x[1] - expected[i][1]).abs() < 1e-5,
"instance {i}: got {:?}, expected {:?}",
sol.x,
expected[i]
);
}
}
}