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 intermediate_callback(
188 &mut self,
189 stats: IterStats,
190 ip_data: &IpoptData,
191 ip_cq: &IpoptCq,
192 ) -> bool {
193 self.inner.intermediate_callback(stats, ip_data, ip_cq)
194 }
195 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
196 self.inner.finalize_metadata(var, con)
197 }
198}
199
200pub fn install_serial_feral_backend(app: &mut IpoptApplication) {
206 let mut cfg = crate::application::feral_config_from_options(app.options());
207 cfg.parallel = Some(false);
208 app.set_linear_backend_factory(Box::new(move |_choice| {
209 Box::new(pounce_feral::FeralSolverInterface::with_config(cfg.clone()))
210 }));
211}
212
213pub struct FeralBackendPool {
244 cfg: pounce_feral::FeralConfig,
245 slots: Mutex<HashMap<ThreadId, pounce_feral::FeralSolverInterface>>,
246}
247
248impl FeralBackendPool {
249 pub fn serial(mut cfg: pounce_feral::FeralConfig) -> Arc<Self> {
253 cfg.parallel = Some(false);
254 Arc::new(Self {
255 cfg,
256 slots: Mutex::new(HashMap::new()),
257 })
258 }
259
260 fn acquire(self: &Arc<Self>) -> PooledFeralBackend {
263 let recycled = self
264 .slots
265 .lock()
266 .ok()
267 .and_then(|mut s| s.remove(&thread::current().id()));
268 let mut inner = recycled
269 .unwrap_or_else(|| pounce_feral::FeralSolverInterface::with_config(self.cfg.clone()));
270 inner.set_reused_scaling_zero_pivot_recheck(true);
273 PooledFeralBackend {
274 inner: Some(inner),
275 pool: Arc::clone(self),
276 }
277 }
278}
279
280impl std::fmt::Debug for FeralBackendPool {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 let parked = self.slots.lock().map(|s| s.len()).unwrap_or(0);
283 f.debug_struct("FeralBackendPool")
284 .field("parked", &parked)
285 .finish_non_exhaustive()
286 }
287}
288
289struct PooledFeralBackend {
296 inner: Option<pounce_feral::FeralSolverInterface>,
297 pool: Arc<FeralBackendPool>,
298}
299
300impl PooledFeralBackend {
301 fn get(&mut self) -> &mut pounce_feral::FeralSolverInterface {
302 #[allow(clippy::expect_used)]
304 self.inner.as_mut().expect("pooled backend already taken")
305 }
306}
307
308impl Drop for PooledFeralBackend {
309 fn drop(&mut self) {
310 if let (Some(solver), Ok(mut slots)) = (self.inner.take(), self.pool.slots.lock()) {
311 slots.entry(thread::current().id()).or_insert(solver);
312 }
313 }
314}
315
316impl SparseSymLinearSolverInterface for PooledFeralBackend {
317 fn initialize_structure(
318 &mut self,
319 dim: Index,
320 nonzeros: Index,
321 ia: &[Index],
322 ja: &[Index],
323 ) -> ESymSolverStatus {
324 self.get().initialize_structure(dim, nonzeros, ia, ja)
325 }
326 fn values_array_mut(&mut self) -> &mut [Number] {
327 self.get().values_array_mut()
328 }
329 fn multi_solve(
330 &mut self,
331 new_matrix: bool,
332 ia: &[Index],
333 ja: &[Index],
334 nrhs: Index,
335 rhs_vals: &mut [Number],
336 check_neg_evals: bool,
337 number_of_neg_evals: Index,
338 ) -> ESymSolverStatus {
339 self.get().multi_solve(
340 new_matrix,
341 ia,
342 ja,
343 nrhs,
344 rhs_vals,
345 check_neg_evals,
346 number_of_neg_evals,
347 )
348 }
349 fn number_of_neg_evals(&self) -> Index {
350 match &self.inner {
351 Some(s) => s.number_of_neg_evals(),
352 None => 0,
353 }
354 }
355 fn increase_quality(&mut self) -> bool {
356 self.get().increase_quality()
357 }
358 fn provides_inertia(&self) -> bool {
359 self.inner.as_ref().is_some_and(|s| s.provides_inertia())
360 }
361 fn set_reused_scaling_zero_pivot_recheck(&mut self, on: bool) {
370 self.get().set_reused_scaling_zero_pivot_recheck(on);
371 }
372 fn singularity_certified(&self) -> bool {
373 self.inner
374 .as_ref()
375 .is_some_and(|s| s.singularity_certified())
376 }
377 fn multi_solve_matches_single_solve(&self, nrhs: usize) -> bool {
378 self.inner
379 .as_ref()
380 .is_some_and(|s| s.multi_solve_matches_single_solve(nrhs))
381 }
382 fn matrix_format(&self) -> EMatrixFormat {
383 match &self.inner {
384 Some(s) => s.matrix_format(),
385 None => EMatrixFormat::TripletFormat,
386 }
387 }
388 fn provides_degeneracy_detection(&self) -> bool {
389 self.inner
390 .as_ref()
391 .is_some_and(|s| s.provides_degeneracy_detection())
392 }
393 fn determine_dependent_rows(
394 &mut self,
395 n_rows: Index,
396 n_cols: Index,
397 irn: &[Index],
398 jcn: &[Index],
399 vals: &[Number],
400 c_deps: &mut Vec<Index>,
401 ) -> ESymSolverStatus {
402 self.get()
403 .determine_dependent_rows(n_rows, n_cols, irn, jcn, vals, c_deps)
404 }
405 fn factor_pattern(&self, want_values: bool) -> Option<pounce_linsol::FactorPattern> {
406 self.inner
407 .as_ref()
408 .and_then(|s| s.factor_pattern(want_values))
409 }
410}
411
412pub fn install_pooled_serial_feral_backend(
417 app: &mut IpoptApplication,
418 pool: &Arc<FeralBackendPool>,
419) {
420 let pool = Arc::clone(pool);
421 app.set_linear_backend_factory(Box::new(move |_choice| Box::new(pool.acquire())));
422}
423
424#[derive(Debug, Clone, Default)]
430pub struct NlpWarmStart {
431 pub x: Vec<Number>,
432 pub lambda: Vec<Number>,
434 pub z_l: Vec<Number>,
436 pub z_u: Vec<Number>,
437 pub mu: Option<Number>,
446}
447
448impl From<&NlpBatchSolution> for NlpWarmStart {
449 fn from(sol: &NlpBatchSolution) -> Self {
450 Self {
451 x: sol.x.clone(),
452 lambda: sol.lambda.clone(),
453 z_l: sol.z_l.clone(),
454 z_u: sol.z_u.clone(),
455 mu: None,
456 }
457 }
458}
459
460impl From<&NlpBatchResult> for NlpWarmStart {
465 fn from(r: &NlpBatchResult) -> Self {
466 match &r.solution {
467 Some(sol) => Self {
468 mu: Some(r.stats.final_mu),
469 ..Self::from(sol)
470 },
471 None => Self::default(),
472 }
473 }
474}
475
476const WARM_MU_FLOOR: Number = 1e-9;
478
479fn apply_warm_options(app: &mut IpoptApplication, mu: Option<Number>) {
484 let _ = app
485 .options_mut()
486 .set_string_value("warm_start_init_point", "yes", true, false);
487 if let Some(mu) = mu {
488 let user_set_mu = matches!(
489 app.options().get_numeric_value("mu_init", ""),
490 Ok((_, true))
491 );
492 if !user_set_mu {
493 let _ =
494 app.options_mut()
495 .set_numeric_value("mu_init", mu.max(WARM_MU_FLOOR), true, false);
496 }
497 }
498}
499
500struct WarmStartTnlp<T: TNLP> {
507 inner: T,
508 warm: NlpWarmStart,
509}
510
511impl<T: TNLP> TNLP for WarmStartTnlp<T> {
512 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
513 self.inner.get_nlp_info()
514 }
515 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
516 self.inner.get_bounds_info(b)
517 }
518 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
519 let dims_ok = self.warm.x.len() == sp.x.len()
520 && (!sp.init_lambda || self.warm.lambda.len() == sp.lambda.len())
521 && (!sp.init_z
522 || (self.warm.z_l.len() == sp.z_l.len() && self.warm.z_u.len() == sp.z_u.len()));
523 if !dims_ok {
524 return self.inner.get_starting_point(sp);
525 }
526 if sp.init_x {
527 sp.x.copy_from_slice(&self.warm.x);
528 }
529 if sp.init_z {
530 sp.z_l.copy_from_slice(&self.warm.z_l);
531 sp.z_u.copy_from_slice(&self.warm.z_u);
532 }
533 if sp.init_lambda {
534 sp.lambda.copy_from_slice(&self.warm.lambda);
535 }
536 true
537 }
538 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
539 self.inner.eval_f(x, new_x)
540 }
541 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
542 self.inner.eval_grad_f(x, new_x, grad_f)
543 }
544 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
545 self.inner.eval_g(x, new_x, g)
546 }
547 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
548 self.inner.eval_jac_g(x, new_x, mode)
549 }
550 fn eval_h(
551 &mut self,
552 x: Option<&[Number]>,
553 new_x: bool,
554 obj_factor: Number,
555 lambda: Option<&[Number]>,
556 new_lambda: bool,
557 mode: SparsityRequest<'_>,
558 ) -> bool {
559 self.inner
560 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
561 }
562 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
563 self.inner.finalize_solution(sol, ip_data, ip_cq)
564 }
565 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
566 self.inner.get_var_con_metadata(var, con)
567 }
568 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
569 self.inner.get_scaling_parameters(req)
570 }
571 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
572 self.inner.get_variables_linearity(types)
573 }
574 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
575 self.inner.get_objective_variables_linearity(types)
576 }
577 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
578 self.inner.get_constraints_linearity(types)
579 }
580 fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
581 self.inner.get_number_of_nonlinear_variables()
582 }
583 fn get_list_of_nonlinear_variables(
584 &mut self,
585 pos_nonlin_vars: &mut [pounce_common::types::Index],
586 ) -> bool {
587 self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
588 }
589 fn wants_intermediate_callback(&self) -> bool {
590 self.inner.wants_intermediate_callback()
591 }
592
593 fn wants_current_iterate(&self) -> bool {
594 self.inner.wants_current_iterate()
595 }
596
597 fn intermediate_callback(
598 &mut self,
599 stats: IterStats,
600 ip_data: &IpoptData,
601 ip_cq: &IpoptCq,
602 ) -> bool {
603 self.inner.intermediate_callback(stats, ip_data, ip_cq)
604 }
605 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
606 self.inner.finalize_metadata(var, con)
607 }
608}
609
610fn solve_nlp_one<T, C>(index: usize, tnlp: T, configure: &mut C) -> NlpBatchResult
622where
623 T: TNLP + 'static,
624 C: FnMut(usize, &mut IpoptApplication),
625{
626 let mut app = IpoptApplication::new();
627 configure(index, &mut app);
628 let cap = Rc::new(RefCell::new(CaptureTnlp {
629 inner: tnlp,
630 captured: None,
631 }));
632 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
636 let t0 = std::time::Instant::now();
637 let status = app.optimize_tnlp(Rc::clone(&cap) as Rc<RefCell<dyn TNLP>>);
638 let wall_time = t0.elapsed().as_secs_f64();
639 let stats = app.statistics();
640 let solution = cap.borrow_mut().captured.take();
641 NlpBatchResult {
642 status,
643 solution,
644 stats,
645 wall_time,
646 }
647 }));
648 outcome.unwrap_or_else(|_| NlpBatchResult {
649 status: ApplicationReturnStatus::InternalError,
650 solution: None,
651 stats: SolveStatistics::default(),
652 wall_time: 0.0,
653 })
654}
655
656pub fn solve_nlp_batch<T, C>(problems: Vec<T>, mut configure: C) -> Vec<NlpBatchResult>
664where
665 T: TNLP + 'static,
666 C: FnMut(usize, &mut IpoptApplication),
667{
668 problems
669 .into_iter()
670 .enumerate()
671 .map(|(i, t)| solve_nlp_one(i, t, &mut configure))
672 .collect()
673}
674
675pub fn solve_nlp_batch_parallel<T, C>(problems: Vec<T>, configure: C) -> Vec<NlpBatchResult>
690where
691 T: TNLP + Send + 'static,
692 C: Fn(usize, &mut IpoptApplication) + Sync,
693{
694 problems
695 .into_par_iter()
696 .enumerate()
697 .map(|(i, t)| {
698 solve_nlp_one(i, t, &mut |i: usize, app: &mut IpoptApplication| {
699 configure(i, app)
700 })
701 })
702 .collect()
703}
704
705pub fn solve_nlp_batch_warm<T, C>(
713 problems: Vec<T>,
714 warms: Vec<NlpWarmStart>,
715 mut configure: C,
716) -> Vec<NlpBatchResult>
717where
718 T: TNLP + 'static,
719 C: FnMut(usize, &mut IpoptApplication),
720{
721 assert_eq!(
722 warms.len(),
723 problems.len(),
724 "warms.len() ({}) must equal problems.len() ({})",
725 warms.len(),
726 problems.len()
727 );
728 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
729 let wrapped: Vec<WarmStartTnlp<T>> = problems
730 .into_iter()
731 .zip(warms)
732 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
733 .collect();
734 solve_nlp_batch(wrapped, |i, app: &mut IpoptApplication| {
735 configure(i, app);
736 apply_warm_options(app, mus[i]);
737 })
738}
739
740pub fn solve_nlp_batch_parallel_warm<T, C>(
758 problems: Vec<T>,
759 warms: Vec<NlpWarmStart>,
760 configure: C,
761) -> Vec<NlpBatchResult>
762where
763 T: TNLP + Send + 'static,
764 C: Fn(usize, &mut IpoptApplication) + Sync,
765{
766 assert_eq!(
767 warms.len(),
768 problems.len(),
769 "warms.len() ({}) must equal problems.len() ({})",
770 warms.len(),
771 problems.len()
772 );
773 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
774 let wrapped: Vec<WarmStartTnlp<T>> = problems
775 .into_iter()
776 .zip(warms)
777 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
778 .collect();
779 solve_nlp_batch_parallel(wrapped, |i, app: &mut IpoptApplication| {
780 configure(i, app);
781 apply_warm_options(app, mus[i]);
782 })
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use pounce_nlp::tnlp::IndexStyle;
789
790 struct ShiftedQuad {
794 a: f64,
795 b: f64,
796 s: f64,
797 x_l: [f64; 2],
799 x_u: [f64; 2],
800 }
801
802 impl ShiftedQuad {
803 fn new(a: f64, b: f64, s: f64) -> Self {
804 Self {
805 a,
806 b,
807 s,
808 x_l: [-1e19; 2],
809 x_u: [1e19; 2],
810 }
811 }
812 fn expected(&self) -> [f64; 2] {
813 let t = (self.s - self.a - self.b) / 2.0;
814 [self.a + t, self.b + t]
815 }
816 }
817
818 impl TNLP for ShiftedQuad {
819 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
820 Some(NlpInfo {
821 n: 2,
822 m: 1,
823 nnz_jac_g: 2,
824 nnz_h_lag: 2,
825 index_style: IndexStyle::C,
826 })
827 }
828 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
829 b.x_l.copy_from_slice(&self.x_l);
830 b.x_u.copy_from_slice(&self.x_u);
831 b.g_l[0] = self.s;
832 b.g_u[0] = self.s;
833 true
834 }
835 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
836 sp.x[0] = 0.0;
837 sp.x[1] = 0.0;
838 true
839 }
840 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
841 Some((x[0] - self.a).powi(2) + (x[1] - self.b).powi(2))
842 }
843 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
844 grad_f[0] = 2.0 * (x[0] - self.a);
845 grad_f[1] = 2.0 * (x[1] - self.b);
846 true
847 }
848 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
849 g[0] = x[0] + x[1];
850 true
851 }
852 fn eval_jac_g(
853 &mut self,
854 _x: Option<&[Number]>,
855 _new_x: bool,
856 mode: SparsityRequest<'_>,
857 ) -> bool {
858 match mode {
859 SparsityRequest::Structure { irow, jcol } => {
860 irow.copy_from_slice(&[0, 0]);
861 jcol.copy_from_slice(&[0, 1]);
862 }
863 SparsityRequest::Values { values } => {
864 values.copy_from_slice(&[1.0, 1.0]);
865 }
866 }
867 true
868 }
869 fn eval_h(
870 &mut self,
871 _x: Option<&[Number]>,
872 _new_x: bool,
873 obj_factor: Number,
874 _lambda: Option<&[Number]>,
875 _new_lambda: bool,
876 mode: SparsityRequest<'_>,
877 ) -> bool {
878 match mode {
879 SparsityRequest::Structure { irow, jcol } => {
880 irow.copy_from_slice(&[0, 1]);
881 jcol.copy_from_slice(&[0, 1]);
882 }
883 SparsityRequest::Values { values } => {
884 values.copy_from_slice(&[2.0 * obj_factor, 2.0 * obj_factor]);
885 }
886 }
887 true
888 }
889 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
890 }
891
892 fn configure(_i: usize, app: &mut IpoptApplication) {
893 let _ = app
894 .options_mut()
895 .set_integer_value("print_level", 0, true, false);
896 install_serial_feral_backend(app);
897 }
898
899 fn batch(k: usize) -> Vec<ShiftedQuad> {
900 (0..k)
901 .map(|i| ShiftedQuad::new(1.0 + i as f64, 2.0, 1.0 + (i % 3) as f64))
902 .collect()
903 }
904
905 struct BoomQuad {
910 inner: ShiftedQuad,
911 boom: bool,
912 }
913
914 impl TNLP for BoomQuad {
915 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
916 self.inner.get_nlp_info()
917 }
918 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
919 self.inner.get_bounds_info(b)
920 }
921 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
922 self.inner.get_starting_point(sp)
923 }
924 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
925 if self.boom {
926 panic!("boom: simulated mid-solve panic in eval_f");
927 }
928 self.inner.eval_f(x, new_x)
929 }
930 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
931 self.inner.eval_grad_f(x, new_x, grad_f)
932 }
933 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
934 self.inner.eval_g(x, new_x, g)
935 }
936 fn eval_jac_g(
937 &mut self,
938 x: Option<&[Number]>,
939 new_x: bool,
940 mode: SparsityRequest<'_>,
941 ) -> bool {
942 self.inner.eval_jac_g(x, new_x, mode)
943 }
944 fn eval_h(
945 &mut self,
946 x: Option<&[Number]>,
947 new_x: bool,
948 obj_factor: Number,
949 lambda: Option<&[Number]>,
950 new_lambda: bool,
951 mode: SparsityRequest<'_>,
952 ) -> bool {
953 self.inner
954 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
955 }
956 fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
957 self.inner.finalize_solution(sol, d, q)
958 }
959 }
960
961 #[test]
962 fn empty_batch_returns_empty() {
963 let out = solve_nlp_batch_parallel(Vec::<ShiftedQuad>::new(), configure);
964 assert!(out.is_empty());
965 }
966
967 #[test]
968 fn single_element_batch_solves() {
969 let probs = batch(1);
970 let expected = probs[0].expected();
971 let out = solve_nlp_batch_parallel(probs, configure);
972 assert_eq!(out.len(), 1);
973 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
974 let sol = out[0].solution.as_ref().expect("solution captured");
975 assert!((sol.x[0] - expected[0]).abs() < 1e-6);
976 assert!((sol.x[1] - expected[1]).abs() < 1e-6);
977 assert!(out[0].wall_time > 0.0 && out[0].wall_time.is_finite());
979 }
980
981 #[test]
982 fn parallel_results_in_input_order_and_match_sequential() {
983 let k = 8;
984 let expected: Vec<[f64; 2]> = batch(k).iter().map(|p| p.expected()).collect();
985 let par = solve_nlp_batch_parallel(batch(k), configure);
986 let seq = solve_nlp_batch(batch(k), configure);
987 assert_eq!(par.len(), k);
988 for i in 0..k {
989 assert_eq!(
990 par[i].status,
991 ApplicationReturnStatus::SolveSucceeded,
992 "instance {i}"
993 );
994 let ps = par[i].solution.as_ref().expect("parallel solution");
995 let ss = seq[i].solution.as_ref().expect("sequential solution");
996 assert!(
998 (ps.x[0] - expected[i][0]).abs() < 1e-6 && (ps.x[1] - expected[i][1]).abs() < 1e-6,
999 "instance {i}: got {:?}, expected {:?}",
1000 ps.x,
1001 expected[i]
1002 );
1003 assert_eq!(ps.x, ss.x, "instance {i}");
1006 assert_eq!(
1007 par[i].stats.iteration_count, seq[i].stats.iteration_count,
1008 "instance {i}"
1009 );
1010 }
1011 }
1012
1013 #[test]
1014 fn infeasible_instance_mixed_in_does_not_poison_batch() {
1015 let mut probs = batch(3);
1018 probs[1].s = 10.0;
1019 probs[1].x_u = [1.0; 2];
1020 let out = solve_nlp_batch_parallel(probs, configure);
1021 assert_eq!(out.len(), 3);
1022 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
1023 assert_eq!(out[2].status, ApplicationReturnStatus::SolveSucceeded);
1024 assert_ne!(
1025 out[1].status,
1026 ApplicationReturnStatus::SolveSucceeded,
1027 "infeasible instance must not report success"
1028 );
1029 }
1030
1031 #[test]
1032 fn panicking_instance_does_not_poison_batch() {
1033 let good = batch(3);
1039 let expected: Vec<[f64; 2]> = good.iter().map(|p| p.expected()).collect();
1040 let probs: Vec<BoomQuad> = good
1041 .into_iter()
1042 .enumerate()
1043 .map(|(i, inner)| BoomQuad {
1044 inner,
1045 boom: i == 1,
1046 })
1047 .collect();
1048 let out = solve_nlp_batch_parallel(probs, configure);
1049 assert_eq!(out.len(), 3);
1050 assert_eq!(out[1].status, ApplicationReturnStatus::InternalError);
1051 assert!(
1052 out[1].solution.is_none(),
1053 "a panicked instance carries no captured solution"
1054 );
1055 for i in [0, 2] {
1056 assert_eq!(out[i].status, ApplicationReturnStatus::SolveSucceeded);
1057 let sol = out[i].solution.as_ref().expect("solution");
1058 assert!(
1059 (sol.x[0] - expected[i][0]).abs() < 1e-6
1060 && (sol.x[1] - expected[i][1]).abs() < 1e-6,
1061 "instance {i}: got {:?}, expected {:?}",
1062 sol.x,
1063 expected[i]
1064 );
1065 }
1066 let probs_seq: Vec<BoomQuad> = batch(3)
1068 .into_iter()
1069 .enumerate()
1070 .map(|(i, inner)| BoomQuad {
1071 inner,
1072 boom: i == 1,
1073 })
1074 .collect();
1075 let seq = solve_nlp_batch(probs_seq, configure);
1076 assert_eq!(seq[1].status, ApplicationReturnStatus::InternalError);
1077 assert_eq!(seq[0].status, ApplicationReturnStatus::SolveSucceeded);
1078 assert_eq!(seq[2].status, ApplicationReturnStatus::SolveSucceeded);
1079 }
1080
1081 #[test]
1087 fn warm_started_batch_chains() {
1088 let k = 4;
1089 let cold = solve_nlp_batch_parallel(batch(k), configure);
1090 let warms: Vec<NlpWarmStart> = cold.iter().map(NlpWarmStart::from).collect();
1091
1092 let perturbed = || -> Vec<ShiftedQuad> {
1094 batch(k)
1095 .into_iter()
1096 .map(|mut p| {
1097 p.s += 0.01;
1098 p
1099 })
1100 .collect()
1101 };
1102 let warm = solve_nlp_batch_parallel_warm(perturbed(), warms, configure);
1103 let cold2 = solve_nlp_batch_parallel(perturbed(), configure);
1104 for i in 0..k {
1105 assert_eq!(warm[i].status, ApplicationReturnStatus::SolveSucceeded);
1106 let expect = perturbed()[i].expected();
1107 let sol = warm[i].solution.as_ref().expect("warm solution");
1108 assert!(
1109 (sol.x[0] - expect[0]).abs() < 1e-5 && (sol.x[1] - expect[1]).abs() < 1e-5,
1110 "instance {i}: warm solve must reach the perturbed optimum"
1111 );
1112 assert!(
1113 warm[i].stats.iteration_count <= cold2[i].stats.iteration_count,
1114 "instance {i}: warm start took {} iters vs cold {}",
1115 warm[i].stats.iteration_count,
1116 cold2[i].stats.iteration_count
1117 );
1118 }
1119 }
1120
1121 #[test]
1127 fn pooled_backend_forwards_singularity_certified() {
1128 use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
1129 let ia: [Index; 4] = [1, 2, 2, 3];
1131 let ja: [Index; 4] = [1, 1, 2, 3];
1132 let vals = [1.0, 1.0, 1.0, 1.0];
1133 fn factor(
1134 b: &mut dyn SparseSymLinearSolverInterface,
1135 ia: &[Index],
1136 ja: &[Index],
1137 v: &[f64],
1138 ) {
1139 assert_eq!(
1140 b.initialize_structure(3, 4, ia, ja),
1141 pounce_linsol::ESymSolverStatus::Success
1142 );
1143 b.values_array_mut().copy_from_slice(v);
1144 let mut rhs = [1.0, 1.0, 1.0];
1145 assert_eq!(
1146 b.multi_solve(true, ia, ja, 1, &mut rhs, true, 1),
1147 pounce_linsol::ESymSolverStatus::Singular
1148 );
1149 }
1150 let cfg = pounce_feral::FeralConfig {
1151 parallel: Some(false),
1152 ..pounce_feral::FeralConfig::default()
1153 };
1154 let mut fresh = pounce_feral::FeralSolverInterface::with_config(cfg.clone());
1155 factor(&mut fresh, &ia, &ja, &vals);
1156 assert!(
1157 fresh.singularity_certified(),
1158 "the bare backend must certify an exact zero pivot, or this test proves nothing"
1159 );
1160 let pool = FeralBackendPool::serial(cfg);
1161 let mut pooled = pool.acquire();
1162 factor(&mut pooled, &ia, &ja, &vals);
1163 assert_eq!(
1164 pooled.singularity_certified(),
1165 fresh.singularity_certified()
1166 );
1167 assert_eq!(
1168 pooled.multi_solve_matches_single_solve(1),
1169 fresh.multi_solve_matches_single_solve(1)
1170 );
1171 }
1172
1173 #[test]
1176 fn warm_start_dimension_mismatch_falls_back_cold() {
1177 let probs = batch(2);
1178 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1179 let warms = vec![NlpWarmStart::default(), NlpWarmStart::default()];
1180 let out = solve_nlp_batch_parallel_warm(probs, warms, configure);
1181 for (i, r) in out.iter().enumerate() {
1182 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1183 let sol = r.solution.as_ref().expect("solution");
1184 assert!(
1185 (sol.x[0] - expected[i][0]).abs() < 1e-6
1186 && (sol.x[1] - expected[i][1]).abs() < 1e-6
1187 );
1188 }
1189 }
1190
1191 #[test]
1195 fn backend_pool_is_send_sync() {
1196 fn assert_send_sync<T: Send + Sync>() {}
1197 assert_send_sync::<FeralBackendPool>();
1198 fn assert_send<T: Send>() {}
1199 assert_send::<pounce_feral::FeralSolverInterface>();
1200 }
1201
1202 #[test]
1206 fn pooled_backends_match_fresh_on_identical_structure() {
1207 let k = 8;
1208 let fresh = solve_nlp_batch_parallel(batch(k), configure);
1209
1210 let pool = FeralBackendPool::serial(pounce_feral::FeralConfig::default());
1211 let pool_for_cfg = Arc::clone(&pool);
1212 let pooled = solve_nlp_batch_parallel(batch(k), move |_i, app| {
1213 let _ = app
1214 .options_mut()
1215 .set_integer_value("print_level", 0, true, false);
1216 install_pooled_serial_feral_backend(app, &pool_for_cfg);
1217 });
1218 assert!(
1219 pool.slots.lock().map(|s| !s.is_empty()).unwrap_or(false),
1220 "at least one worker must have parked its backend"
1221 );
1222 for i in 0..k {
1223 assert_eq!(pooled[i].status, ApplicationReturnStatus::SolveSucceeded);
1224 let pf = fresh[i].solution.as_ref().expect("fresh");
1225 let pp = pooled[i].solution.as_ref().expect("pooled");
1226 for j in 0..2 {
1227 assert!(
1228 (pf.x[j] - pp.x[j]).abs() < 1e-6,
1229 "instance {i} x[{j}]: pooled {} vs fresh {}",
1230 pp.x[j],
1231 pf.x[j]
1232 );
1233 }
1234 }
1235 }
1236
1237 #[test]
1241 fn ragged_iteration_counts_keep_order() {
1242 let probs: Vec<ShiftedQuad> = (0..6)
1243 .map(|i| ShiftedQuad::new(10f64.powi(i - 3), 2.0, 1.0))
1244 .collect();
1245 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1246 let out = solve_nlp_batch_parallel(probs, configure);
1247 for (i, r) in out.iter().enumerate() {
1248 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1249 let sol = r.solution.as_ref().expect("solution");
1250 assert!(
1251 (sol.x[0] - expected[i][0]).abs() < 1e-5
1252 && (sol.x[1] - expected[i][1]).abs() < 1e-5,
1253 "instance {i}: got {:?}, expected {:?}",
1254 sol.x,
1255 expected[i]
1256 );
1257 }
1258 }
1259}