1use crate::application::IpoptApplication;
50use pounce_common::types::{Index, Number};
51use pounce_linsol::{EMatrixFormat, ESymSolverStatus, SparseSymLinearSolverInterface};
52use pounce_nlp::alg_types::SolverReturn;
53use pounce_nlp::return_codes::ApplicationReturnStatus;
54use pounce_nlp::solve_statistics::SolveStatistics;
55use pounce_nlp::tnlp::{
56 BoundsInfo, IpoptCq, IpoptData, IterStats, Linearity, MetaData, NlpInfo, ScalingRequest,
57 Solution, SparsityRequest, StartingPoint, TNLP,
58};
59use rayon::prelude::*;
60use std::cell::RefCell;
61use std::collections::HashMap;
62use std::rc::Rc;
63use std::sync::{Arc, Mutex};
64use std::thread::{self, ThreadId};
65
66#[derive(Debug, Clone)]
70pub struct NlpBatchSolution {
71 pub solver_status: SolverReturn,
74 pub x: Vec<Number>,
75 pub z_l: Vec<Number>,
77 pub z_u: Vec<Number>,
78 pub g: Vec<Number>,
80 pub lambda: Vec<Number>,
82 pub obj: Number,
83}
84
85#[derive(Debug, Clone)]
87pub struct NlpBatchResult {
88 pub status: ApplicationReturnStatus,
89 pub solution: Option<NlpBatchSolution>,
92 pub stats: SolveStatistics,
95 pub wall_time: f64,
99}
100
101struct CaptureTnlp<T: TNLP> {
105 inner: T,
106 captured: Option<NlpBatchSolution>,
107}
108
109impl<T: TNLP> TNLP for CaptureTnlp<T> {
110 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
111 self.inner.get_nlp_info()
112 }
113 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
114 self.inner.get_bounds_info(b)
115 }
116 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
117 self.inner.get_starting_point(sp)
118 }
119 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
120 self.inner.eval_f(x, new_x)
121 }
122 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
123 self.inner.eval_grad_f(x, new_x, grad_f)
124 }
125 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
126 self.inner.eval_g(x, new_x, g)
127 }
128 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
129 self.inner.eval_jac_g(x, new_x, mode)
130 }
131 fn eval_h(
132 &mut self,
133 x: Option<&[Number]>,
134 new_x: bool,
135 obj_factor: Number,
136 lambda: Option<&[Number]>,
137 new_lambda: bool,
138 mode: SparsityRequest<'_>,
139 ) -> bool {
140 self.inner
141 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
142 }
143 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
144 self.captured = Some(NlpBatchSolution {
145 solver_status: sol.status,
146 x: sol.x.to_vec(),
147 z_l: sol.z_l.to_vec(),
148 z_u: sol.z_u.to_vec(),
149 g: sol.g.to_vec(),
150 lambda: sol.lambda.to_vec(),
151 obj: sol.obj_value,
152 });
153 self.inner.finalize_solution(sol, ip_data, ip_cq);
154 }
155 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
156 self.inner.get_var_con_metadata(var, con)
157 }
158 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
159 self.inner.get_scaling_parameters(req)
160 }
161 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
162 self.inner.get_variables_linearity(types)
163 }
164 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
165 self.inner.get_objective_variables_linearity(types)
166 }
167 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
168 self.inner.get_constraints_linearity(types)
169 }
170 fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
171 self.inner.get_number_of_nonlinear_variables()
172 }
173 fn get_list_of_nonlinear_variables(
174 &mut self,
175 pos_nonlin_vars: &mut [pounce_common::types::Index],
176 ) -> bool {
177 self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
178 }
179 fn wants_intermediate_callback(&self) -> bool {
180 self.inner.wants_intermediate_callback()
181 }
182
183 fn wants_current_iterate(&self) -> bool {
184 self.inner.wants_current_iterate()
185 }
186
187 fn update_with_barrier(&mut self, mu: Number) -> bool {
188 self.inner.update_with_barrier(mu)
189 }
190
191 fn intermediate_callback(
192 &mut self,
193 stats: IterStats,
194 ip_data: &IpoptData,
195 ip_cq: &IpoptCq,
196 ) -> bool {
197 self.inner.intermediate_callback(stats, ip_data, ip_cq)
198 }
199 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
200 self.inner.finalize_metadata(var, con)
201 }
202}
203
204pub fn install_serial_feral_backend(app: &mut IpoptApplication) {
210 let mut cfg = crate::application::feral_config_from_options(app.options());
211 cfg.parallel = Some(false);
212 app.set_linear_backend_factory(Box::new(move |_choice| {
213 Box::new(pounce_feral::FeralSolverInterface::with_config(cfg.clone()))
214 }));
215}
216
217pub struct FeralBackendPool {
248 cfg: pounce_feral::FeralConfig,
249 slots: Mutex<HashMap<ThreadId, pounce_feral::FeralSolverInterface>>,
250}
251
252impl FeralBackendPool {
253 pub fn serial(mut cfg: pounce_feral::FeralConfig) -> Arc<Self> {
257 cfg.parallel = Some(false);
258 Arc::new(Self {
259 cfg,
260 slots: Mutex::new(HashMap::new()),
261 })
262 }
263
264 fn acquire(self: &Arc<Self>) -> PooledFeralBackend {
267 let recycled = self
268 .slots
269 .lock()
270 .ok()
271 .and_then(|mut s| s.remove(&thread::current().id()));
272 let mut inner = recycled
273 .unwrap_or_else(|| pounce_feral::FeralSolverInterface::with_config(self.cfg.clone()));
274 inner.set_reused_scaling_zero_pivot_recheck(true);
277 PooledFeralBackend {
278 inner: Some(inner),
279 pool: Arc::clone(self),
280 }
281 }
282}
283
284impl std::fmt::Debug for FeralBackendPool {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 let parked = self.slots.lock().map(|s| s.len()).unwrap_or(0);
287 f.debug_struct("FeralBackendPool")
288 .field("parked", &parked)
289 .finish_non_exhaustive()
290 }
291}
292
293struct PooledFeralBackend {
300 inner: Option<pounce_feral::FeralSolverInterface>,
301 pool: Arc<FeralBackendPool>,
302}
303
304impl PooledFeralBackend {
305 fn get(&mut self) -> &mut pounce_feral::FeralSolverInterface {
306 #[allow(clippy::expect_used)]
308 self.inner.as_mut().expect("pooled backend already taken")
309 }
310}
311
312impl Drop for PooledFeralBackend {
313 fn drop(&mut self) {
314 if let (Some(solver), Ok(mut slots)) = (self.inner.take(), self.pool.slots.lock()) {
315 slots.entry(thread::current().id()).or_insert(solver);
316 }
317 }
318}
319
320impl SparseSymLinearSolverInterface for PooledFeralBackend {
321 fn initialize_structure(
322 &mut self,
323 dim: Index,
324 nonzeros: Index,
325 ia: &[Index],
326 ja: &[Index],
327 ) -> ESymSolverStatus {
328 self.get().initialize_structure(dim, nonzeros, ia, ja)
329 }
330 fn values_array_mut(&mut self) -> &mut [Number] {
331 self.get().values_array_mut()
332 }
333 fn multi_solve(
334 &mut self,
335 new_matrix: bool,
336 ia: &[Index],
337 ja: &[Index],
338 nrhs: Index,
339 rhs_vals: &mut [Number],
340 check_neg_evals: bool,
341 number_of_neg_evals: Index,
342 ) -> ESymSolverStatus {
343 self.get().multi_solve(
344 new_matrix,
345 ia,
346 ja,
347 nrhs,
348 rhs_vals,
349 check_neg_evals,
350 number_of_neg_evals,
351 )
352 }
353 fn number_of_neg_evals(&self) -> Index {
354 match &self.inner {
355 Some(s) => s.number_of_neg_evals(),
356 None => 0,
357 }
358 }
359 fn increase_quality(&mut self) -> bool {
360 self.get().increase_quality()
361 }
362 fn provides_inertia(&self) -> bool {
363 self.inner.as_ref().is_some_and(|s| s.provides_inertia())
364 }
365 fn set_reused_scaling_zero_pivot_recheck(&mut self, on: bool) {
374 self.get().set_reused_scaling_zero_pivot_recheck(on);
375 }
376 fn singularity_certified(&self) -> bool {
377 self.inner
378 .as_ref()
379 .is_some_and(|s| s.singularity_certified())
380 }
381 fn multi_solve_matches_single_solve(&self, nrhs: usize) -> bool {
382 self.inner
383 .as_ref()
384 .is_some_and(|s| s.multi_solve_matches_single_solve(nrhs))
385 }
386 fn matrix_format(&self) -> EMatrixFormat {
387 match &self.inner {
388 Some(s) => s.matrix_format(),
389 None => EMatrixFormat::TripletFormat,
390 }
391 }
392 fn provides_degeneracy_detection(&self) -> bool {
393 self.inner
394 .as_ref()
395 .is_some_and(|s| s.provides_degeneracy_detection())
396 }
397 fn determine_dependent_rows(
398 &mut self,
399 n_rows: Index,
400 n_cols: Index,
401 irn: &[Index],
402 jcn: &[Index],
403 vals: &[Number],
404 c_deps: &mut Vec<Index>,
405 ) -> ESymSolverStatus {
406 self.get()
407 .determine_dependent_rows(n_rows, n_cols, irn, jcn, vals, c_deps)
408 }
409 fn factor_pattern(&self, want_values: bool) -> Option<pounce_linsol::FactorPattern> {
410 self.inner
411 .as_ref()
412 .and_then(|s| s.factor_pattern(want_values))
413 }
414}
415
416pub fn install_pooled_serial_feral_backend(
421 app: &mut IpoptApplication,
422 pool: &Arc<FeralBackendPool>,
423) {
424 let pool = Arc::clone(pool);
425 app.set_linear_backend_factory(Box::new(move |_choice| Box::new(pool.acquire())));
426}
427
428#[derive(Debug, Clone, Default)]
434pub struct NlpWarmStart {
435 pub x: Vec<Number>,
436 pub lambda: Vec<Number>,
438 pub z_l: Vec<Number>,
440 pub z_u: Vec<Number>,
441 pub mu: Option<Number>,
450}
451
452impl From<&NlpBatchSolution> for NlpWarmStart {
453 fn from(sol: &NlpBatchSolution) -> Self {
454 Self {
455 x: sol.x.clone(),
456 lambda: sol.lambda.clone(),
457 z_l: sol.z_l.clone(),
458 z_u: sol.z_u.clone(),
459 mu: None,
460 }
461 }
462}
463
464impl From<&NlpBatchResult> for NlpWarmStart {
469 fn from(r: &NlpBatchResult) -> Self {
470 match &r.solution {
471 Some(sol) => Self {
472 mu: Some(r.stats.final_mu),
473 ..Self::from(sol)
474 },
475 None => Self::default(),
476 }
477 }
478}
479
480const WARM_MU_FLOOR: Number = 1e-9;
482
483fn apply_warm_options(app: &mut IpoptApplication, mu: Option<Number>) {
488 let _ = app
489 .options_mut()
490 .set_string_value("warm_start_init_point", "yes", true, false);
491 if let Some(mu) = mu {
492 let user_set_mu = matches!(
493 app.options().get_numeric_value("mu_init", ""),
494 Ok((_, true))
495 );
496 if !user_set_mu {
497 let _ =
498 app.options_mut()
499 .set_numeric_value("mu_init", mu.max(WARM_MU_FLOOR), true, false);
500 }
501 }
502}
503
504struct WarmStartTnlp<T: TNLP> {
511 inner: T,
512 warm: NlpWarmStart,
513}
514
515impl<T: TNLP> TNLP for WarmStartTnlp<T> {
516 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
517 self.inner.get_nlp_info()
518 }
519 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
520 self.inner.get_bounds_info(b)
521 }
522 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
523 let dims_ok = self.warm.x.len() == sp.x.len()
524 && (!sp.init_lambda || self.warm.lambda.len() == sp.lambda.len())
525 && (!sp.init_z
526 || (self.warm.z_l.len() == sp.z_l.len() && self.warm.z_u.len() == sp.z_u.len()));
527 if !dims_ok {
528 return self.inner.get_starting_point(sp);
529 }
530 if sp.init_x {
531 sp.x.copy_from_slice(&self.warm.x);
532 }
533 if sp.init_z {
534 sp.z_l.copy_from_slice(&self.warm.z_l);
535 sp.z_u.copy_from_slice(&self.warm.z_u);
536 }
537 if sp.init_lambda {
538 sp.lambda.copy_from_slice(&self.warm.lambda);
539 }
540 true
541 }
542 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
543 self.inner.eval_f(x, new_x)
544 }
545 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
546 self.inner.eval_grad_f(x, new_x, grad_f)
547 }
548 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
549 self.inner.eval_g(x, new_x, g)
550 }
551 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
552 self.inner.eval_jac_g(x, new_x, mode)
553 }
554 fn eval_h(
555 &mut self,
556 x: Option<&[Number]>,
557 new_x: bool,
558 obj_factor: Number,
559 lambda: Option<&[Number]>,
560 new_lambda: bool,
561 mode: SparsityRequest<'_>,
562 ) -> bool {
563 self.inner
564 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
565 }
566 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
567 self.inner.finalize_solution(sol, ip_data, ip_cq)
568 }
569 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
570 self.inner.get_var_con_metadata(var, con)
571 }
572 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
573 self.inner.get_scaling_parameters(req)
574 }
575 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
576 self.inner.get_variables_linearity(types)
577 }
578 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
579 self.inner.get_objective_variables_linearity(types)
580 }
581 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
582 self.inner.get_constraints_linearity(types)
583 }
584 fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
585 self.inner.get_number_of_nonlinear_variables()
586 }
587 fn get_list_of_nonlinear_variables(
588 &mut self,
589 pos_nonlin_vars: &mut [pounce_common::types::Index],
590 ) -> bool {
591 self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
592 }
593 fn wants_intermediate_callback(&self) -> bool {
594 self.inner.wants_intermediate_callback()
595 }
596
597 fn wants_current_iterate(&self) -> bool {
598 self.inner.wants_current_iterate()
599 }
600
601 fn update_with_barrier(&mut self, mu: Number) -> bool {
602 self.inner.update_with_barrier(mu)
603 }
604
605 fn intermediate_callback(
606 &mut self,
607 stats: IterStats,
608 ip_data: &IpoptData,
609 ip_cq: &IpoptCq,
610 ) -> bool {
611 self.inner.intermediate_callback(stats, ip_data, ip_cq)
612 }
613 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
614 self.inner.finalize_metadata(var, con)
615 }
616}
617
618fn solve_nlp_one<T, C>(index: usize, tnlp: T, configure: &mut C) -> NlpBatchResult
630where
631 T: TNLP + 'static,
632 C: FnMut(usize, &mut IpoptApplication),
633{
634 let mut app = IpoptApplication::new();
635 configure(index, &mut app);
636 let cap = Rc::new(RefCell::new(CaptureTnlp {
637 inner: tnlp,
638 captured: None,
639 }));
640 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
644 let t0 = std::time::Instant::now();
645 let status = app.optimize_tnlp(Rc::clone(&cap) as Rc<RefCell<dyn TNLP>>);
646 let wall_time = t0.elapsed().as_secs_f64();
647 let stats = app.statistics();
648 let solution = cap.borrow_mut().captured.take();
649 NlpBatchResult {
650 status,
651 solution,
652 stats,
653 wall_time,
654 }
655 }));
656 outcome.unwrap_or_else(|_| NlpBatchResult {
657 status: ApplicationReturnStatus::InternalError,
658 solution: None,
659 stats: SolveStatistics::default(),
660 wall_time: 0.0,
661 })
662}
663
664pub fn solve_nlp_batch<T, C>(problems: Vec<T>, mut configure: C) -> Vec<NlpBatchResult>
672where
673 T: TNLP + 'static,
674 C: FnMut(usize, &mut IpoptApplication),
675{
676 problems
677 .into_iter()
678 .enumerate()
679 .map(|(i, t)| solve_nlp_one(i, t, &mut configure))
680 .collect()
681}
682
683pub fn solve_nlp_batch_parallel<T, C>(problems: Vec<T>, configure: C) -> Vec<NlpBatchResult>
698where
699 T: TNLP + Send + 'static,
700 C: Fn(usize, &mut IpoptApplication) + Sync,
701{
702 problems
703 .into_par_iter()
704 .enumerate()
705 .map(|(i, t)| {
706 solve_nlp_one(i, t, &mut |i: usize, app: &mut IpoptApplication| {
707 configure(i, app)
708 })
709 })
710 .collect()
711}
712
713pub fn solve_nlp_batch_warm<T, C>(
721 problems: Vec<T>,
722 warms: Vec<NlpWarmStart>,
723 mut configure: C,
724) -> Vec<NlpBatchResult>
725where
726 T: TNLP + 'static,
727 C: FnMut(usize, &mut IpoptApplication),
728{
729 assert_eq!(
730 warms.len(),
731 problems.len(),
732 "warms.len() ({}) must equal problems.len() ({})",
733 warms.len(),
734 problems.len()
735 );
736 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
737 let wrapped: Vec<WarmStartTnlp<T>> = problems
738 .into_iter()
739 .zip(warms)
740 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
741 .collect();
742 solve_nlp_batch(wrapped, |i, app: &mut IpoptApplication| {
743 configure(i, app);
744 apply_warm_options(app, mus[i]);
745 })
746}
747
748pub fn solve_nlp_batch_parallel_warm<T, C>(
766 problems: Vec<T>,
767 warms: Vec<NlpWarmStart>,
768 configure: C,
769) -> Vec<NlpBatchResult>
770where
771 T: TNLP + Send + 'static,
772 C: Fn(usize, &mut IpoptApplication) + Sync,
773{
774 assert_eq!(
775 warms.len(),
776 problems.len(),
777 "warms.len() ({}) must equal problems.len() ({})",
778 warms.len(),
779 problems.len()
780 );
781 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
782 let wrapped: Vec<WarmStartTnlp<T>> = problems
783 .into_iter()
784 .zip(warms)
785 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
786 .collect();
787 solve_nlp_batch_parallel(wrapped, |i, app: &mut IpoptApplication| {
788 configure(i, app);
789 apply_warm_options(app, mus[i]);
790 })
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use pounce_nlp::tnlp::IndexStyle;
797
798 struct ShiftedQuad {
802 a: f64,
803 b: f64,
804 s: f64,
805 x_l: [f64; 2],
807 x_u: [f64; 2],
808 }
809
810 impl ShiftedQuad {
811 fn new(a: f64, b: f64, s: f64) -> Self {
812 Self {
813 a,
814 b,
815 s,
816 x_l: [-1e19; 2],
817 x_u: [1e19; 2],
818 }
819 }
820 fn expected(&self) -> [f64; 2] {
821 let t = (self.s - self.a - self.b) / 2.0;
822 [self.a + t, self.b + t]
823 }
824 }
825
826 impl TNLP for ShiftedQuad {
827 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
828 Some(NlpInfo {
829 n: 2,
830 m: 1,
831 nnz_jac_g: 2,
832 nnz_h_lag: 2,
833 index_style: IndexStyle::C,
834 })
835 }
836 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
837 b.x_l.copy_from_slice(&self.x_l);
838 b.x_u.copy_from_slice(&self.x_u);
839 b.g_l[0] = self.s;
840 b.g_u[0] = self.s;
841 true
842 }
843 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
844 sp.x[0] = 0.0;
845 sp.x[1] = 0.0;
846 true
847 }
848 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
849 Some((x[0] - self.a).powi(2) + (x[1] - self.b).powi(2))
850 }
851 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
852 grad_f[0] = 2.0 * (x[0] - self.a);
853 grad_f[1] = 2.0 * (x[1] - self.b);
854 true
855 }
856 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
857 g[0] = x[0] + x[1];
858 true
859 }
860 fn eval_jac_g(
861 &mut self,
862 _x: Option<&[Number]>,
863 _new_x: bool,
864 mode: SparsityRequest<'_>,
865 ) -> bool {
866 match mode {
867 SparsityRequest::Structure { irow, jcol } => {
868 irow.copy_from_slice(&[0, 0]);
869 jcol.copy_from_slice(&[0, 1]);
870 }
871 SparsityRequest::Values { values } => {
872 values.copy_from_slice(&[1.0, 1.0]);
873 }
874 }
875 true
876 }
877 fn eval_h(
878 &mut self,
879 _x: Option<&[Number]>,
880 _new_x: bool,
881 obj_factor: Number,
882 _lambda: Option<&[Number]>,
883 _new_lambda: bool,
884 mode: SparsityRequest<'_>,
885 ) -> bool {
886 match mode {
887 SparsityRequest::Structure { irow, jcol } => {
888 irow.copy_from_slice(&[0, 1]);
889 jcol.copy_from_slice(&[0, 1]);
890 }
891 SparsityRequest::Values { values } => {
892 values.copy_from_slice(&[2.0 * obj_factor, 2.0 * obj_factor]);
893 }
894 }
895 true
896 }
897 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
898 }
899
900 fn configure(_i: usize, app: &mut IpoptApplication) {
901 let _ = app
902 .options_mut()
903 .set_integer_value("print_level", 0, true, false);
904 install_serial_feral_backend(app);
905 }
906
907 fn batch(k: usize) -> Vec<ShiftedQuad> {
908 (0..k)
909 .map(|i| ShiftedQuad::new(1.0 + i as f64, 2.0, 1.0 + (i % 3) as f64))
910 .collect()
911 }
912
913 struct BoomQuad {
918 inner: ShiftedQuad,
919 boom: bool,
920 }
921
922 impl TNLP for BoomQuad {
923 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
924 self.inner.get_nlp_info()
925 }
926 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
927 self.inner.get_bounds_info(b)
928 }
929 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
930 self.inner.get_starting_point(sp)
931 }
932 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
933 if self.boom {
934 panic!("boom: simulated mid-solve panic in eval_f");
935 }
936 self.inner.eval_f(x, new_x)
937 }
938 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
939 self.inner.eval_grad_f(x, new_x, grad_f)
940 }
941 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
942 self.inner.eval_g(x, new_x, g)
943 }
944 fn eval_jac_g(
945 &mut self,
946 x: Option<&[Number]>,
947 new_x: bool,
948 mode: SparsityRequest<'_>,
949 ) -> bool {
950 self.inner.eval_jac_g(x, new_x, mode)
951 }
952 fn eval_h(
953 &mut self,
954 x: Option<&[Number]>,
955 new_x: bool,
956 obj_factor: Number,
957 lambda: Option<&[Number]>,
958 new_lambda: bool,
959 mode: SparsityRequest<'_>,
960 ) -> bool {
961 self.inner
962 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
963 }
964 fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
965 self.inner.finalize_solution(sol, d, q)
966 }
967 }
968
969 #[test]
970 fn empty_batch_returns_empty() {
971 let out = solve_nlp_batch_parallel(Vec::<ShiftedQuad>::new(), configure);
972 assert!(out.is_empty());
973 }
974
975 #[test]
976 fn single_element_batch_solves() {
977 let probs = batch(1);
978 let expected = probs[0].expected();
979 let out = solve_nlp_batch_parallel(probs, configure);
980 assert_eq!(out.len(), 1);
981 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
982 let sol = out[0].solution.as_ref().expect("solution captured");
983 assert!((sol.x[0] - expected[0]).abs() < 1e-6);
984 assert!((sol.x[1] - expected[1]).abs() < 1e-6);
985 assert!(out[0].wall_time > 0.0 && out[0].wall_time.is_finite());
987 }
988
989 #[test]
990 fn parallel_results_in_input_order_and_match_sequential() {
991 let k = 8;
992 let expected: Vec<[f64; 2]> = batch(k).iter().map(|p| p.expected()).collect();
993 let par = solve_nlp_batch_parallel(batch(k), configure);
994 let seq = solve_nlp_batch(batch(k), configure);
995 assert_eq!(par.len(), k);
996 for i in 0..k {
997 assert_eq!(
998 par[i].status,
999 ApplicationReturnStatus::SolveSucceeded,
1000 "instance {i}"
1001 );
1002 let ps = par[i].solution.as_ref().expect("parallel solution");
1003 let ss = seq[i].solution.as_ref().expect("sequential solution");
1004 assert!(
1006 (ps.x[0] - expected[i][0]).abs() < 1e-6 && (ps.x[1] - expected[i][1]).abs() < 1e-6,
1007 "instance {i}: got {:?}, expected {:?}",
1008 ps.x,
1009 expected[i]
1010 );
1011 assert_eq!(ps.x, ss.x, "instance {i}");
1014 assert_eq!(
1015 par[i].stats.iteration_count, seq[i].stats.iteration_count,
1016 "instance {i}"
1017 );
1018 }
1019 }
1020
1021 #[test]
1022 fn infeasible_instance_mixed_in_does_not_poison_batch() {
1023 let mut probs = batch(3);
1026 probs[1].s = 10.0;
1027 probs[1].x_u = [1.0; 2];
1028 let out = solve_nlp_batch_parallel(probs, configure);
1029 assert_eq!(out.len(), 3);
1030 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
1031 assert_eq!(out[2].status, ApplicationReturnStatus::SolveSucceeded);
1032 assert_ne!(
1033 out[1].status,
1034 ApplicationReturnStatus::SolveSucceeded,
1035 "infeasible instance must not report success"
1036 );
1037 }
1038
1039 #[test]
1040 fn panicking_instance_does_not_poison_batch() {
1041 let good = batch(3);
1047 let expected: Vec<[f64; 2]> = good.iter().map(|p| p.expected()).collect();
1048 let probs: Vec<BoomQuad> = good
1049 .into_iter()
1050 .enumerate()
1051 .map(|(i, inner)| BoomQuad {
1052 inner,
1053 boom: i == 1,
1054 })
1055 .collect();
1056 let out = solve_nlp_batch_parallel(probs, configure);
1057 assert_eq!(out.len(), 3);
1058 assert_eq!(out[1].status, ApplicationReturnStatus::InternalError);
1059 assert!(
1060 out[1].solution.is_none(),
1061 "a panicked instance carries no captured solution"
1062 );
1063 for i in [0, 2] {
1064 assert_eq!(out[i].status, ApplicationReturnStatus::SolveSucceeded);
1065 let sol = out[i].solution.as_ref().expect("solution");
1066 assert!(
1067 (sol.x[0] - expected[i][0]).abs() < 1e-6
1068 && (sol.x[1] - expected[i][1]).abs() < 1e-6,
1069 "instance {i}: got {:?}, expected {:?}",
1070 sol.x,
1071 expected[i]
1072 );
1073 }
1074 let probs_seq: Vec<BoomQuad> = batch(3)
1076 .into_iter()
1077 .enumerate()
1078 .map(|(i, inner)| BoomQuad {
1079 inner,
1080 boom: i == 1,
1081 })
1082 .collect();
1083 let seq = solve_nlp_batch(probs_seq, configure);
1084 assert_eq!(seq[1].status, ApplicationReturnStatus::InternalError);
1085 assert_eq!(seq[0].status, ApplicationReturnStatus::SolveSucceeded);
1086 assert_eq!(seq[2].status, ApplicationReturnStatus::SolveSucceeded);
1087 }
1088
1089 #[test]
1095 fn warm_started_batch_chains() {
1096 let k = 4;
1097 let cold = solve_nlp_batch_parallel(batch(k), configure);
1098 let warms: Vec<NlpWarmStart> = cold.iter().map(NlpWarmStart::from).collect();
1099
1100 let perturbed = || -> Vec<ShiftedQuad> {
1102 batch(k)
1103 .into_iter()
1104 .map(|mut p| {
1105 p.s += 0.01;
1106 p
1107 })
1108 .collect()
1109 };
1110 let warm = solve_nlp_batch_parallel_warm(perturbed(), warms, configure);
1111 let cold2 = solve_nlp_batch_parallel(perturbed(), configure);
1112 for i in 0..k {
1113 assert_eq!(warm[i].status, ApplicationReturnStatus::SolveSucceeded);
1114 let expect = perturbed()[i].expected();
1115 let sol = warm[i].solution.as_ref().expect("warm solution");
1116 assert!(
1117 (sol.x[0] - expect[0]).abs() < 1e-5 && (sol.x[1] - expect[1]).abs() < 1e-5,
1118 "instance {i}: warm solve must reach the perturbed optimum"
1119 );
1120 assert!(
1121 warm[i].stats.iteration_count <= cold2[i].stats.iteration_count,
1122 "instance {i}: warm start took {} iters vs cold {}",
1123 warm[i].stats.iteration_count,
1124 cold2[i].stats.iteration_count
1125 );
1126 }
1127 }
1128
1129 #[test]
1135 fn pooled_backend_forwards_singularity_certified() {
1136 use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
1137 let ia: [Index; 4] = [1, 2, 2, 3];
1139 let ja: [Index; 4] = [1, 1, 2, 3];
1140 let vals = [1.0, 1.0, 1.0, 1.0];
1141 fn factor(
1142 b: &mut dyn SparseSymLinearSolverInterface,
1143 ia: &[Index],
1144 ja: &[Index],
1145 v: &[f64],
1146 ) {
1147 assert_eq!(
1148 b.initialize_structure(3, 4, ia, ja),
1149 pounce_linsol::ESymSolverStatus::Success
1150 );
1151 b.values_array_mut().copy_from_slice(v);
1152 let mut rhs = [1.0, 1.0, 1.0];
1153 assert_eq!(
1154 b.multi_solve(true, ia, ja, 1, &mut rhs, true, 1),
1155 pounce_linsol::ESymSolverStatus::Singular
1156 );
1157 }
1158 let cfg = pounce_feral::FeralConfig {
1159 parallel: Some(false),
1160 ..pounce_feral::FeralConfig::default()
1161 };
1162 let mut fresh = pounce_feral::FeralSolverInterface::with_config(cfg.clone());
1163 factor(&mut fresh, &ia, &ja, &vals);
1164 assert!(
1165 fresh.singularity_certified(),
1166 "the bare backend must certify an exact zero pivot, or this test proves nothing"
1167 );
1168 let pool = FeralBackendPool::serial(cfg);
1169 let mut pooled = pool.acquire();
1170 factor(&mut pooled, &ia, &ja, &vals);
1171 assert_eq!(
1172 pooled.singularity_certified(),
1173 fresh.singularity_certified()
1174 );
1175 assert_eq!(
1176 pooled.multi_solve_matches_single_solve(1),
1177 fresh.multi_solve_matches_single_solve(1)
1178 );
1179 }
1180
1181 #[test]
1184 fn warm_start_dimension_mismatch_falls_back_cold() {
1185 let probs = batch(2);
1186 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1187 let warms = vec![NlpWarmStart::default(), NlpWarmStart::default()];
1188 let out = solve_nlp_batch_parallel_warm(probs, warms, configure);
1189 for (i, r) in out.iter().enumerate() {
1190 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1191 let sol = r.solution.as_ref().expect("solution");
1192 assert!(
1193 (sol.x[0] - expected[i][0]).abs() < 1e-6
1194 && (sol.x[1] - expected[i][1]).abs() < 1e-6
1195 );
1196 }
1197 }
1198
1199 #[test]
1203 fn backend_pool_is_send_sync() {
1204 fn assert_send_sync<T: Send + Sync>() {}
1205 assert_send_sync::<FeralBackendPool>();
1206 fn assert_send<T: Send>() {}
1207 assert_send::<pounce_feral::FeralSolverInterface>();
1208 }
1209
1210 #[test]
1214 fn pooled_backends_match_fresh_on_identical_structure() {
1215 let k = 8;
1216 let fresh = solve_nlp_batch_parallel(batch(k), configure);
1217
1218 let pool = FeralBackendPool::serial(pounce_feral::FeralConfig::default());
1219 let pool_for_cfg = Arc::clone(&pool);
1220 let pooled = solve_nlp_batch_parallel(batch(k), move |_i, app| {
1221 let _ = app
1222 .options_mut()
1223 .set_integer_value("print_level", 0, true, false);
1224 install_pooled_serial_feral_backend(app, &pool_for_cfg);
1225 });
1226 assert!(
1227 pool.slots.lock().map(|s| !s.is_empty()).unwrap_or(false),
1228 "at least one worker must have parked its backend"
1229 );
1230 for i in 0..k {
1231 assert_eq!(pooled[i].status, ApplicationReturnStatus::SolveSucceeded);
1232 let pf = fresh[i].solution.as_ref().expect("fresh");
1233 let pp = pooled[i].solution.as_ref().expect("pooled");
1234 for j in 0..2 {
1235 assert!(
1236 (pf.x[j] - pp.x[j]).abs() < 1e-6,
1237 "instance {i} x[{j}]: pooled {} vs fresh {}",
1238 pp.x[j],
1239 pf.x[j]
1240 );
1241 }
1242 }
1243 }
1244
1245 #[test]
1249 fn ragged_iteration_counts_keep_order() {
1250 let probs: Vec<ShiftedQuad> = (0..6)
1251 .map(|i| ShiftedQuad::new(10f64.powi(i - 3), 2.0, 1.0))
1252 .collect();
1253 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1254 let out = solve_nlp_batch_parallel(probs, configure);
1255 for (i, r) in out.iter().enumerate() {
1256 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1257 let sol = r.solution.as_ref().expect("solution");
1258 assert!(
1259 (sol.x[0] - expected[i][0]).abs() < 1e-5
1260 && (sol.x[1] - expected[i][1]).abs() < 1e-5,
1261 "instance {i}: got {:?}, expected {:?}",
1262 sol.x,
1263 expected[i]
1264 );
1265 }
1266 }
1267}