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 inner = recycled
269 .unwrap_or_else(|| pounce_feral::FeralSolverInterface::with_config(self.cfg.clone()));
270 PooledFeralBackend {
271 inner: Some(inner),
272 pool: Arc::clone(self),
273 }
274 }
275}
276
277impl std::fmt::Debug for FeralBackendPool {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 let parked = self.slots.lock().map(|s| s.len()).unwrap_or(0);
280 f.debug_struct("FeralBackendPool")
281 .field("parked", &parked)
282 .finish_non_exhaustive()
283 }
284}
285
286struct PooledFeralBackend {
293 inner: Option<pounce_feral::FeralSolverInterface>,
294 pool: Arc<FeralBackendPool>,
295}
296
297impl PooledFeralBackend {
298 fn get(&mut self) -> &mut pounce_feral::FeralSolverInterface {
299 #[allow(clippy::expect_used)]
301 self.inner.as_mut().expect("pooled backend already taken")
302 }
303}
304
305impl Drop for PooledFeralBackend {
306 fn drop(&mut self) {
307 if let (Some(solver), Ok(mut slots)) = (self.inner.take(), self.pool.slots.lock()) {
308 slots.entry(thread::current().id()).or_insert(solver);
309 }
310 }
311}
312
313impl SparseSymLinearSolverInterface for PooledFeralBackend {
314 fn initialize_structure(
315 &mut self,
316 dim: Index,
317 nonzeros: Index,
318 ia: &[Index],
319 ja: &[Index],
320 ) -> ESymSolverStatus {
321 self.get().initialize_structure(dim, nonzeros, ia, ja)
322 }
323 fn values_array_mut(&mut self) -> &mut [Number] {
324 self.get().values_array_mut()
325 }
326 fn multi_solve(
327 &mut self,
328 new_matrix: bool,
329 ia: &[Index],
330 ja: &[Index],
331 nrhs: Index,
332 rhs_vals: &mut [Number],
333 check_neg_evals: bool,
334 number_of_neg_evals: Index,
335 ) -> ESymSolverStatus {
336 self.get().multi_solve(
337 new_matrix,
338 ia,
339 ja,
340 nrhs,
341 rhs_vals,
342 check_neg_evals,
343 number_of_neg_evals,
344 )
345 }
346 fn number_of_neg_evals(&self) -> Index {
347 match &self.inner {
348 Some(s) => s.number_of_neg_evals(),
349 None => 0,
350 }
351 }
352 fn increase_quality(&mut self) -> bool {
353 self.get().increase_quality()
354 }
355 fn provides_inertia(&self) -> bool {
356 self.inner.as_ref().is_some_and(|s| s.provides_inertia())
357 }
358 fn matrix_format(&self) -> EMatrixFormat {
359 match &self.inner {
360 Some(s) => s.matrix_format(),
361 None => EMatrixFormat::TripletFormat,
362 }
363 }
364 fn provides_degeneracy_detection(&self) -> bool {
365 self.inner
366 .as_ref()
367 .is_some_and(|s| s.provides_degeneracy_detection())
368 }
369 fn determine_dependent_rows(
370 &mut self,
371 n_rows: Index,
372 n_cols: Index,
373 irn: &[Index],
374 jcn: &[Index],
375 vals: &[Number],
376 c_deps: &mut Vec<Index>,
377 ) -> ESymSolverStatus {
378 self.get()
379 .determine_dependent_rows(n_rows, n_cols, irn, jcn, vals, c_deps)
380 }
381 fn factor_pattern(&self, want_values: bool) -> Option<pounce_linsol::FactorPattern> {
382 self.inner
383 .as_ref()
384 .and_then(|s| s.factor_pattern(want_values))
385 }
386}
387
388pub fn install_pooled_serial_feral_backend(
393 app: &mut IpoptApplication,
394 pool: &Arc<FeralBackendPool>,
395) {
396 let pool = Arc::clone(pool);
397 app.set_linear_backend_factory(Box::new(move |_choice| Box::new(pool.acquire())));
398}
399
400#[derive(Debug, Clone, Default)]
406pub struct NlpWarmStart {
407 pub x: Vec<Number>,
408 pub lambda: Vec<Number>,
410 pub z_l: Vec<Number>,
412 pub z_u: Vec<Number>,
413 pub mu: Option<Number>,
422}
423
424impl From<&NlpBatchSolution> for NlpWarmStart {
425 fn from(sol: &NlpBatchSolution) -> Self {
426 Self {
427 x: sol.x.clone(),
428 lambda: sol.lambda.clone(),
429 z_l: sol.z_l.clone(),
430 z_u: sol.z_u.clone(),
431 mu: None,
432 }
433 }
434}
435
436impl From<&NlpBatchResult> for NlpWarmStart {
441 fn from(r: &NlpBatchResult) -> Self {
442 match &r.solution {
443 Some(sol) => Self {
444 mu: Some(r.stats.final_mu),
445 ..Self::from(sol)
446 },
447 None => Self::default(),
448 }
449 }
450}
451
452const WARM_MU_FLOOR: Number = 1e-9;
454
455fn apply_warm_options(app: &mut IpoptApplication, mu: Option<Number>) {
460 let _ = app
461 .options_mut()
462 .set_string_value("warm_start_init_point", "yes", true, false);
463 if let Some(mu) = mu {
464 let user_set_mu = matches!(
465 app.options().get_numeric_value("mu_init", ""),
466 Ok((_, true))
467 );
468 if !user_set_mu {
469 let _ =
470 app.options_mut()
471 .set_numeric_value("mu_init", mu.max(WARM_MU_FLOOR), true, false);
472 }
473 }
474}
475
476struct WarmStartTnlp<T: TNLP> {
483 inner: T,
484 warm: NlpWarmStart,
485}
486
487impl<T: TNLP> TNLP for WarmStartTnlp<T> {
488 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
489 self.inner.get_nlp_info()
490 }
491 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
492 self.inner.get_bounds_info(b)
493 }
494 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
495 let dims_ok = self.warm.x.len() == sp.x.len()
496 && (!sp.init_lambda || self.warm.lambda.len() == sp.lambda.len())
497 && (!sp.init_z
498 || (self.warm.z_l.len() == sp.z_l.len() && self.warm.z_u.len() == sp.z_u.len()));
499 if !dims_ok {
500 return self.inner.get_starting_point(sp);
501 }
502 if sp.init_x {
503 sp.x.copy_from_slice(&self.warm.x);
504 }
505 if sp.init_z {
506 sp.z_l.copy_from_slice(&self.warm.z_l);
507 sp.z_u.copy_from_slice(&self.warm.z_u);
508 }
509 if sp.init_lambda {
510 sp.lambda.copy_from_slice(&self.warm.lambda);
511 }
512 true
513 }
514 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
515 self.inner.eval_f(x, new_x)
516 }
517 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
518 self.inner.eval_grad_f(x, new_x, grad_f)
519 }
520 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
521 self.inner.eval_g(x, new_x, g)
522 }
523 fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
524 self.inner.eval_jac_g(x, new_x, mode)
525 }
526 fn eval_h(
527 &mut self,
528 x: Option<&[Number]>,
529 new_x: bool,
530 obj_factor: Number,
531 lambda: Option<&[Number]>,
532 new_lambda: bool,
533 mode: SparsityRequest<'_>,
534 ) -> bool {
535 self.inner
536 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
537 }
538 fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
539 self.inner.finalize_solution(sol, ip_data, ip_cq)
540 }
541 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
542 self.inner.get_var_con_metadata(var, con)
543 }
544 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
545 self.inner.get_scaling_parameters(req)
546 }
547 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
548 self.inner.get_variables_linearity(types)
549 }
550 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
551 self.inner.get_objective_variables_linearity(types)
552 }
553 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
554 self.inner.get_constraints_linearity(types)
555 }
556 fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
557 self.inner.get_number_of_nonlinear_variables()
558 }
559 fn get_list_of_nonlinear_variables(
560 &mut self,
561 pos_nonlin_vars: &mut [pounce_common::types::Index],
562 ) -> bool {
563 self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
564 }
565 fn wants_intermediate_callback(&self) -> bool {
566 self.inner.wants_intermediate_callback()
567 }
568
569 fn wants_current_iterate(&self) -> bool {
570 self.inner.wants_current_iterate()
571 }
572
573 fn intermediate_callback(
574 &mut self,
575 stats: IterStats,
576 ip_data: &IpoptData,
577 ip_cq: &IpoptCq,
578 ) -> bool {
579 self.inner.intermediate_callback(stats, ip_data, ip_cq)
580 }
581 fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
582 self.inner.finalize_metadata(var, con)
583 }
584}
585
586fn solve_nlp_one<T, C>(index: usize, tnlp: T, configure: &mut C) -> NlpBatchResult
598where
599 T: TNLP + 'static,
600 C: FnMut(usize, &mut IpoptApplication),
601{
602 let mut app = IpoptApplication::new();
603 configure(index, &mut app);
604 let cap = Rc::new(RefCell::new(CaptureTnlp {
605 inner: tnlp,
606 captured: None,
607 }));
608 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
612 let t0 = std::time::Instant::now();
613 let status = app.optimize_tnlp(Rc::clone(&cap) as Rc<RefCell<dyn TNLP>>);
614 let wall_time = t0.elapsed().as_secs_f64();
615 let stats = app.statistics();
616 let solution = cap.borrow_mut().captured.take();
617 NlpBatchResult {
618 status,
619 solution,
620 stats,
621 wall_time,
622 }
623 }));
624 outcome.unwrap_or_else(|_| NlpBatchResult {
625 status: ApplicationReturnStatus::InternalError,
626 solution: None,
627 stats: SolveStatistics::default(),
628 wall_time: 0.0,
629 })
630}
631
632pub fn solve_nlp_batch<T, C>(problems: Vec<T>, mut configure: C) -> Vec<NlpBatchResult>
640where
641 T: TNLP + 'static,
642 C: FnMut(usize, &mut IpoptApplication),
643{
644 problems
645 .into_iter()
646 .enumerate()
647 .map(|(i, t)| solve_nlp_one(i, t, &mut configure))
648 .collect()
649}
650
651pub fn solve_nlp_batch_parallel<T, C>(problems: Vec<T>, configure: C) -> Vec<NlpBatchResult>
666where
667 T: TNLP + Send + 'static,
668 C: Fn(usize, &mut IpoptApplication) + Sync,
669{
670 problems
671 .into_par_iter()
672 .enumerate()
673 .map(|(i, t)| {
674 solve_nlp_one(i, t, &mut |i: usize, app: &mut IpoptApplication| {
675 configure(i, app)
676 })
677 })
678 .collect()
679}
680
681pub fn solve_nlp_batch_warm<T, C>(
689 problems: Vec<T>,
690 warms: Vec<NlpWarmStart>,
691 mut configure: C,
692) -> Vec<NlpBatchResult>
693where
694 T: TNLP + 'static,
695 C: FnMut(usize, &mut IpoptApplication),
696{
697 assert_eq!(
698 warms.len(),
699 problems.len(),
700 "warms.len() ({}) must equal problems.len() ({})",
701 warms.len(),
702 problems.len()
703 );
704 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
705 let wrapped: Vec<WarmStartTnlp<T>> = problems
706 .into_iter()
707 .zip(warms)
708 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
709 .collect();
710 solve_nlp_batch(wrapped, |i, app: &mut IpoptApplication| {
711 configure(i, app);
712 apply_warm_options(app, mus[i]);
713 })
714}
715
716pub fn solve_nlp_batch_parallel_warm<T, C>(
734 problems: Vec<T>,
735 warms: Vec<NlpWarmStart>,
736 configure: C,
737) -> Vec<NlpBatchResult>
738where
739 T: TNLP + Send + 'static,
740 C: Fn(usize, &mut IpoptApplication) + Sync,
741{
742 assert_eq!(
743 warms.len(),
744 problems.len(),
745 "warms.len() ({}) must equal problems.len() ({})",
746 warms.len(),
747 problems.len()
748 );
749 let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
750 let wrapped: Vec<WarmStartTnlp<T>> = problems
751 .into_iter()
752 .zip(warms)
753 .map(|(inner, warm)| WarmStartTnlp { inner, warm })
754 .collect();
755 solve_nlp_batch_parallel(wrapped, |i, app: &mut IpoptApplication| {
756 configure(i, app);
757 apply_warm_options(app, mus[i]);
758 })
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use pounce_nlp::tnlp::IndexStyle;
765
766 struct ShiftedQuad {
770 a: f64,
771 b: f64,
772 s: f64,
773 x_l: [f64; 2],
775 x_u: [f64; 2],
776 }
777
778 impl ShiftedQuad {
779 fn new(a: f64, b: f64, s: f64) -> Self {
780 Self {
781 a,
782 b,
783 s,
784 x_l: [-1e19; 2],
785 x_u: [1e19; 2],
786 }
787 }
788 fn expected(&self) -> [f64; 2] {
789 let t = (self.s - self.a - self.b) / 2.0;
790 [self.a + t, self.b + t]
791 }
792 }
793
794 impl TNLP for ShiftedQuad {
795 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
796 Some(NlpInfo {
797 n: 2,
798 m: 1,
799 nnz_jac_g: 2,
800 nnz_h_lag: 2,
801 index_style: IndexStyle::C,
802 })
803 }
804 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
805 b.x_l.copy_from_slice(&self.x_l);
806 b.x_u.copy_from_slice(&self.x_u);
807 b.g_l[0] = self.s;
808 b.g_u[0] = self.s;
809 true
810 }
811 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
812 sp.x[0] = 0.0;
813 sp.x[1] = 0.0;
814 true
815 }
816 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
817 Some((x[0] - self.a).powi(2) + (x[1] - self.b).powi(2))
818 }
819 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
820 grad_f[0] = 2.0 * (x[0] - self.a);
821 grad_f[1] = 2.0 * (x[1] - self.b);
822 true
823 }
824 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
825 g[0] = x[0] + x[1];
826 true
827 }
828 fn eval_jac_g(
829 &mut self,
830 _x: Option<&[Number]>,
831 _new_x: bool,
832 mode: SparsityRequest<'_>,
833 ) -> bool {
834 match mode {
835 SparsityRequest::Structure { irow, jcol } => {
836 irow.copy_from_slice(&[0, 0]);
837 jcol.copy_from_slice(&[0, 1]);
838 }
839 SparsityRequest::Values { values } => {
840 values.copy_from_slice(&[1.0, 1.0]);
841 }
842 }
843 true
844 }
845 fn eval_h(
846 &mut self,
847 _x: Option<&[Number]>,
848 _new_x: bool,
849 obj_factor: Number,
850 _lambda: Option<&[Number]>,
851 _new_lambda: bool,
852 mode: SparsityRequest<'_>,
853 ) -> bool {
854 match mode {
855 SparsityRequest::Structure { irow, jcol } => {
856 irow.copy_from_slice(&[0, 1]);
857 jcol.copy_from_slice(&[0, 1]);
858 }
859 SparsityRequest::Values { values } => {
860 values.copy_from_slice(&[2.0 * obj_factor, 2.0 * obj_factor]);
861 }
862 }
863 true
864 }
865 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
866 }
867
868 fn configure(_i: usize, app: &mut IpoptApplication) {
869 let _ = app
870 .options_mut()
871 .set_integer_value("print_level", 0, true, false);
872 install_serial_feral_backend(app);
873 }
874
875 fn batch(k: usize) -> Vec<ShiftedQuad> {
876 (0..k)
877 .map(|i| ShiftedQuad::new(1.0 + i as f64, 2.0, 1.0 + (i % 3) as f64))
878 .collect()
879 }
880
881 struct BoomQuad {
886 inner: ShiftedQuad,
887 boom: bool,
888 }
889
890 impl TNLP for BoomQuad {
891 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
892 self.inner.get_nlp_info()
893 }
894 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
895 self.inner.get_bounds_info(b)
896 }
897 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
898 self.inner.get_starting_point(sp)
899 }
900 fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
901 if self.boom {
902 panic!("boom: simulated mid-solve panic in eval_f");
903 }
904 self.inner.eval_f(x, new_x)
905 }
906 fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
907 self.inner.eval_grad_f(x, new_x, grad_f)
908 }
909 fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
910 self.inner.eval_g(x, new_x, g)
911 }
912 fn eval_jac_g(
913 &mut self,
914 x: Option<&[Number]>,
915 new_x: bool,
916 mode: SparsityRequest<'_>,
917 ) -> bool {
918 self.inner.eval_jac_g(x, new_x, mode)
919 }
920 fn eval_h(
921 &mut self,
922 x: Option<&[Number]>,
923 new_x: bool,
924 obj_factor: Number,
925 lambda: Option<&[Number]>,
926 new_lambda: bool,
927 mode: SparsityRequest<'_>,
928 ) -> bool {
929 self.inner
930 .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
931 }
932 fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
933 self.inner.finalize_solution(sol, d, q)
934 }
935 }
936
937 #[test]
938 fn empty_batch_returns_empty() {
939 let out = solve_nlp_batch_parallel(Vec::<ShiftedQuad>::new(), configure);
940 assert!(out.is_empty());
941 }
942
943 #[test]
944 fn single_element_batch_solves() {
945 let probs = batch(1);
946 let expected = probs[0].expected();
947 let out = solve_nlp_batch_parallel(probs, configure);
948 assert_eq!(out.len(), 1);
949 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
950 let sol = out[0].solution.as_ref().expect("solution captured");
951 assert!((sol.x[0] - expected[0]).abs() < 1e-6);
952 assert!((sol.x[1] - expected[1]).abs() < 1e-6);
953 assert!(out[0].wall_time > 0.0 && out[0].wall_time.is_finite());
955 }
956
957 #[test]
958 fn parallel_results_in_input_order_and_match_sequential() {
959 let k = 8;
960 let expected: Vec<[f64; 2]> = batch(k).iter().map(|p| p.expected()).collect();
961 let par = solve_nlp_batch_parallel(batch(k), configure);
962 let seq = solve_nlp_batch(batch(k), configure);
963 assert_eq!(par.len(), k);
964 for i in 0..k {
965 assert_eq!(
966 par[i].status,
967 ApplicationReturnStatus::SolveSucceeded,
968 "instance {i}"
969 );
970 let ps = par[i].solution.as_ref().expect("parallel solution");
971 let ss = seq[i].solution.as_ref().expect("sequential solution");
972 assert!(
974 (ps.x[0] - expected[i][0]).abs() < 1e-6 && (ps.x[1] - expected[i][1]).abs() < 1e-6,
975 "instance {i}: got {:?}, expected {:?}",
976 ps.x,
977 expected[i]
978 );
979 assert_eq!(ps.x, ss.x, "instance {i}");
982 assert_eq!(
983 par[i].stats.iteration_count, seq[i].stats.iteration_count,
984 "instance {i}"
985 );
986 }
987 }
988
989 #[test]
990 fn infeasible_instance_mixed_in_does_not_poison_batch() {
991 let mut probs = batch(3);
994 probs[1].s = 10.0;
995 probs[1].x_u = [1.0; 2];
996 let out = solve_nlp_batch_parallel(probs, configure);
997 assert_eq!(out.len(), 3);
998 assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
999 assert_eq!(out[2].status, ApplicationReturnStatus::SolveSucceeded);
1000 assert_ne!(
1001 out[1].status,
1002 ApplicationReturnStatus::SolveSucceeded,
1003 "infeasible instance must not report success"
1004 );
1005 }
1006
1007 #[test]
1008 fn panicking_instance_does_not_poison_batch() {
1009 let good = batch(3);
1015 let expected: Vec<[f64; 2]> = good.iter().map(|p| p.expected()).collect();
1016 let probs: Vec<BoomQuad> = good
1017 .into_iter()
1018 .enumerate()
1019 .map(|(i, inner)| BoomQuad {
1020 inner,
1021 boom: i == 1,
1022 })
1023 .collect();
1024 let out = solve_nlp_batch_parallel(probs, configure);
1025 assert_eq!(out.len(), 3);
1026 assert_eq!(out[1].status, ApplicationReturnStatus::InternalError);
1027 assert!(
1028 out[1].solution.is_none(),
1029 "a panicked instance carries no captured solution"
1030 );
1031 for i in [0, 2] {
1032 assert_eq!(out[i].status, ApplicationReturnStatus::SolveSucceeded);
1033 let sol = out[i].solution.as_ref().expect("solution");
1034 assert!(
1035 (sol.x[0] - expected[i][0]).abs() < 1e-6
1036 && (sol.x[1] - expected[i][1]).abs() < 1e-6,
1037 "instance {i}: got {:?}, expected {:?}",
1038 sol.x,
1039 expected[i]
1040 );
1041 }
1042 let probs_seq: Vec<BoomQuad> = batch(3)
1044 .into_iter()
1045 .enumerate()
1046 .map(|(i, inner)| BoomQuad {
1047 inner,
1048 boom: i == 1,
1049 })
1050 .collect();
1051 let seq = solve_nlp_batch(probs_seq, configure);
1052 assert_eq!(seq[1].status, ApplicationReturnStatus::InternalError);
1053 assert_eq!(seq[0].status, ApplicationReturnStatus::SolveSucceeded);
1054 assert_eq!(seq[2].status, ApplicationReturnStatus::SolveSucceeded);
1055 }
1056
1057 #[test]
1063 fn warm_started_batch_chains() {
1064 let k = 4;
1065 let cold = solve_nlp_batch_parallel(batch(k), configure);
1066 let warms: Vec<NlpWarmStart> = cold.iter().map(NlpWarmStart::from).collect();
1067
1068 let perturbed = || -> Vec<ShiftedQuad> {
1070 batch(k)
1071 .into_iter()
1072 .map(|mut p| {
1073 p.s += 0.01;
1074 p
1075 })
1076 .collect()
1077 };
1078 let warm = solve_nlp_batch_parallel_warm(perturbed(), warms, configure);
1079 let cold2 = solve_nlp_batch_parallel(perturbed(), configure);
1080 for i in 0..k {
1081 assert_eq!(warm[i].status, ApplicationReturnStatus::SolveSucceeded);
1082 let expect = perturbed()[i].expected();
1083 let sol = warm[i].solution.as_ref().expect("warm solution");
1084 assert!(
1085 (sol.x[0] - expect[0]).abs() < 1e-5 && (sol.x[1] - expect[1]).abs() < 1e-5,
1086 "instance {i}: warm solve must reach the perturbed optimum"
1087 );
1088 assert!(
1089 warm[i].stats.iteration_count <= cold2[i].stats.iteration_count,
1090 "instance {i}: warm start took {} iters vs cold {}",
1091 warm[i].stats.iteration_count,
1092 cold2[i].stats.iteration_count
1093 );
1094 }
1095 }
1096
1097 #[test]
1100 fn warm_start_dimension_mismatch_falls_back_cold() {
1101 let probs = batch(2);
1102 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1103 let warms = vec![NlpWarmStart::default(), NlpWarmStart::default()];
1104 let out = solve_nlp_batch_parallel_warm(probs, warms, configure);
1105 for (i, r) in out.iter().enumerate() {
1106 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1107 let sol = r.solution.as_ref().expect("solution");
1108 assert!(
1109 (sol.x[0] - expected[i][0]).abs() < 1e-6
1110 && (sol.x[1] - expected[i][1]).abs() < 1e-6
1111 );
1112 }
1113 }
1114
1115 #[test]
1119 fn backend_pool_is_send_sync() {
1120 fn assert_send_sync<T: Send + Sync>() {}
1121 assert_send_sync::<FeralBackendPool>();
1122 fn assert_send<T: Send>() {}
1123 assert_send::<pounce_feral::FeralSolverInterface>();
1124 }
1125
1126 #[test]
1130 fn pooled_backends_match_fresh_on_identical_structure() {
1131 let k = 8;
1132 let fresh = solve_nlp_batch_parallel(batch(k), configure);
1133
1134 let pool = FeralBackendPool::serial(pounce_feral::FeralConfig::default());
1135 let pool_for_cfg = Arc::clone(&pool);
1136 let pooled = solve_nlp_batch_parallel(batch(k), move |_i, app| {
1137 let _ = app
1138 .options_mut()
1139 .set_integer_value("print_level", 0, true, false);
1140 install_pooled_serial_feral_backend(app, &pool_for_cfg);
1141 });
1142 assert!(
1143 pool.slots.lock().map(|s| !s.is_empty()).unwrap_or(false),
1144 "at least one worker must have parked its backend"
1145 );
1146 for i in 0..k {
1147 assert_eq!(pooled[i].status, ApplicationReturnStatus::SolveSucceeded);
1148 let pf = fresh[i].solution.as_ref().expect("fresh");
1149 let pp = pooled[i].solution.as_ref().expect("pooled");
1150 for j in 0..2 {
1151 assert!(
1152 (pf.x[j] - pp.x[j]).abs() < 1e-6,
1153 "instance {i} x[{j}]: pooled {} vs fresh {}",
1154 pp.x[j],
1155 pf.x[j]
1156 );
1157 }
1158 }
1159 }
1160
1161 #[test]
1165 fn ragged_iteration_counts_keep_order() {
1166 let probs: Vec<ShiftedQuad> = (0..6)
1167 .map(|i| ShiftedQuad::new(10f64.powi(i - 3), 2.0, 1.0))
1168 .collect();
1169 let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1170 let out = solve_nlp_batch_parallel(probs, configure);
1171 for (i, r) in out.iter().enumerate() {
1172 assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1173 let sol = r.solution.as_ref().expect("solution");
1174 assert!(
1175 (sol.x[0] - expected[i][0]).abs() < 1e-5
1176 && (sol.x[1] - expected[i][1]).abs() < 1e-5,
1177 "instance {i}: got {:?}, expected {:?}",
1178 sol.x,
1179 expected[i]
1180 );
1181 }
1182 }
1183}