Skip to main content

pounce_algorithm/
batch.rs

1//! Batched NLP solving (pounce#126) — N independent NLPs on a rayon
2//! pool.
3//!
4//! The NLP analog of `pounce-convex`'s `solve_qp_batch` /
5//! `solve_qp_batch_parallel`: solve a batch of independent problems
6//! (parametric sweeps, multi-start, branch-and-bound node relaxations)
7//! and return one result per input, in input order. Each instance runs
8//! the full filter-IPM end-to-end via its own [`IpoptApplication`].
9//!
10//! # Parallelism model: outer-parallel, inner-serial
11//!
12//! Same rationale as the QP batch (`pounce-convex/src/batch.rs`): each
13//! NLP solve is fully independent, so the batch is embarrassingly
14//! parallel *across instances* — but the default FERAL factorization
15//! backend is itself rayon-parallel *within* a factor, and running
16//! both levels oversubscribes the cores. [`solve_nlp_batch_parallel`]
17//! therefore builds everything per worker, and the `configure` hook
18//! should install an **inner-serial** linear-solver backend;
19//! [`install_serial_feral_backend`] does exactly that (it honors the
20//! app's `feral_*` options, forcing only `parallel = off` — a
21//! per-backend setting, not global state). Unlike the QP batch, NLP
22//! instances converge in wildly different iteration counts; that is
23//! fine for thread-per-instance rayon (a converged instance frees its
24//! worker — no lockstep), and no cross-instance KKT-structure sharing
25//! is attempted because general-NLP sparsity may differ per instance.
26//!
27//! # Construction happens on the worker
28//!
29//! `pounce-nlp`'s solver plumbing is single-threaded
30//! (`Rc<RefCell<…>>`), so nothing solver-side crosses a thread
31//! boundary: each worker receives one owned `T: TNLP + Send` (e.g. a
32//! `pounce_nl::nl_reader::NlTnlp`, which is `Send` since its CSE nodes
33//! went `Arc`), constructs its own [`IpoptApplication`] *inside* the
34//! worker closure, and only the plain-data [`NlpBatchResult`] comes
35//! back.
36//!
37//! # The `configure` hook
38//!
39//! `pounce-algorithm` cannot depend on `pounce-restoration` (the dep
40//! edge runs the other way), so the per-worker application is handed
41//! to a caller-supplied `configure` closure that should, at minimum,
42//! mirror what single-solve drivers do: set options, then install a
43//! linear-backend factory and a restoration-factory provider. Without
44//! a restoration provider an instance that needs the restoration
45//! phase returns `RestorationFailure` instead of recovering — same as
46//! a bare `IpoptApplication`. See `pounce-cli`'s solve setup or
47//! `pounce-py`'s `Problem::prepare` for the full recipe.
48
49use 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/// Final iterate of one batch instance, captured from the
67/// `finalize_solution` callback (owned copies of the borrowed
68/// buffers).
69#[derive(Debug, Clone)]
70pub struct NlpBatchSolution {
71    /// Algorithm-level termination status (finer-grained than the
72    /// application-level [`NlpBatchResult::status`]).
73    pub solver_status: SolverReturn,
74    pub x: Vec<Number>,
75    /// Lower / upper bound multipliers.
76    pub z_l: Vec<Number>,
77    pub z_u: Vec<Number>,
78    /// Constraint values `g(x)` at the final iterate.
79    pub g: Vec<Number>,
80    /// Constraint multipliers.
81    pub lambda: Vec<Number>,
82    pub obj: Number,
83}
84
85/// Outcome of one instance of a batched solve.
86#[derive(Debug, Clone)]
87pub struct NlpBatchResult {
88    pub status: ApplicationReturnStatus,
89    /// `None` when the solve aborted before `finalize_solution` ran
90    /// (e.g. `InvalidProblemDefinition`).
91    pub solution: Option<NlpBatchSolution>,
92    /// Per-instance solve statistics (iteration count, final KKT
93    /// error, timings, …).
94    pub stats: SolveStatistics,
95    /// Wall-clock seconds spent in this instance's solve (gh#990 item 11),
96    /// measured around `optimize_tnlp`; `Problem.solve`'s `info["wall_time"]`
97    /// counterpart. `0.0` for an aborted (panicked) instance.
98    pub wall_time: f64,
99}
100
101/// Delegating wrapper that records the `finalize_solution` payload so
102/// the batch driver can return it as owned data. Every other `TNLP`
103/// method forwards to the wrapped instance unchanged.
104struct 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
200/// Install an inner-serial FERAL backend on `app`, honoring any
201/// `feral_*` options already set (read the options *before* calling
202/// this if `configure` sets them — i.e. set options first, then call
203/// this). The `parallel = off` toggle is per-backend; concurrent
204/// solves on other threads are unaffected.
205pub 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
213/// Per-thread pool of serial FERAL backends for **identical-sparsity**
214/// batches (the issue-#126 "optional later optimization": reuse the
215/// symbolic analysis across instances).
216///
217/// FERAL's `Solver` keys its symbolic factorization (fill-reducing
218/// ordering + supernode structure) on a pattern fingerprint and reuses
219/// it across `factor()` calls — that is what already amortizes the
220/// symbolic cost over a single instance's IPM iterations. A fresh
221/// backend per instance throws that cache away between instances; this
222/// pool instead parks each worker thread's backend when its solve
223/// finishes and hands it to the next instance scheduled on that
224/// thread. When the next instance's KKT pattern is identical, its
225/// first factorization hits the cached symbolic; when it differs, the
226/// fingerprint mismatch triggers a fresh analysis — so pooling is
227/// *always correct*, just only *profitable* for shared-structure
228/// sweeps.
229///
230/// **Determinism caveat (why this is opt-in):** the pooled solver
231/// carries value-adjacent state across instances — most notably the
232/// MC64 scaling cache (reused only inside its validity bound) and any
233/// pivot-quality escalation a previous instance triggered. Solutions
234/// still satisfy the same tolerances, but are not guaranteed
235/// bit-identical to fresh-backend solves. The default batch entry
236/// points keep one fresh backend per instance.
237///
238/// Construct once per batch ([`Self::serial`]), share the `Arc` with
239/// each worker's `configure` via
240/// [`install_pooled_serial_feral_backend`]. Restoration-phase inner
241/// solves keep their own fresh backends. Pooled solvers (and their
242/// cached factors) free when the `Arc` drops.
243pub struct FeralBackendPool {
244    cfg: pounce_feral::FeralConfig,
245    slots: Mutex<HashMap<ThreadId, pounce_feral::FeralSolverInterface>>,
246}
247
248impl FeralBackendPool {
249    /// A pool minting inner-serial backends with `cfg` (its `parallel`
250    /// field is forced off — pooling exists for the outer-parallel /
251    /// inner-serial batch shape).
252    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    /// Take this thread's parked backend (or mint a fresh one). The
261    /// returned guard parks it back on drop, on the same thread.
262    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        // A parked solver keeps whatever its last owner set; a new owner
271        // starts from the default.
272        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
289/// RAII guard around a pooled [`pounce_feral::FeralSolverInterface`]:
290/// delegates the backend trait verbatim and parks the solver back into
291/// its pool slot when the solve's ownership chain drops it. If the
292/// slot is already occupied (two live backends on one thread — e.g. a
293/// nested factory call), the extra solver is simply dropped: pooling
294/// degrades to fresh-per-instance, never to shared-mutable state.
295struct 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        // Invariant: `inner` is `Some` from construction until drop.
303        #[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    // gh#1076: the two hooks below have defaults on the trait, so leaving
362    // them out compiled and silently answered `false` for every pooled
363    // solve. `singularity_certified` is what the perturbation handler reads
364    // to decide whether a post-walk-back `Singular` refutes the `δ_c`
365    // withdrawal (gh#981), so a pooled batch member took a different
366    // ladder from the same model solved on a fresh backend. A wrapper that
367    // claims to delegate verbatim has to forward every method the backend
368    // overrides, not only the required ones.
369    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
412/// Install a backend factory drawing from `pool` — the
413/// identical-sparsity batch optimization (see [`FeralBackendPool`] for
414/// the reuse semantics and the determinism caveat). Call from
415/// `configure` *instead of* [`install_serial_feral_backend`].
416pub 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/// Per-instance warm-start iterate for [`solve_nlp_batch_parallel_warm`]:
425/// primal point plus the three dual vectors the IPM's warm-start
426/// initializer consumes via `TNLP::get_starting_point`. Build one from
427/// a previous batch's [`NlpBatchSolution`] (the `From` impl) for MPC /
428/// sequential-chaining workloads.
429#[derive(Debug, Clone, Default)]
430pub struct NlpWarmStart {
431    pub x: Vec<Number>,
432    /// Constraint multipliers λ.
433    pub lambda: Vec<Number>,
434    /// Lower / upper bound multipliers.
435    pub z_l: Vec<Number>,
436    pub z_u: Vec<Number>,
437    /// Barrier parameter μ to resume from — typically the previous
438    /// solve's final μ ([`SolveStatistics::final_mu`]). When `Some`,
439    /// the warm batch sets `mu_init` to it (floored at `1e-9`, and
440    /// only if the caller's `configure` didn't set `mu_init`
441    /// explicitly), so the IPM continues near the converged barrier
442    /// instead of recentering at the cold default `0.1` — without
443    /// this, a warm start from a near-optimal point can *gain*
444    /// iterations walking back out to the central path.
445    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
460/// Build a warm start from a full batch result: iterate + duals from
461/// the captured solution, μ from the statistics. An instance whose
462/// solve produced no solution yields an empty warm start, which the
463/// next solve treats as a cold start (dimension-mismatch fallback).
464impl 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
476/// Floor for warm-start `mu_init` threading (see [`NlpWarmStart::mu`]).
477const WARM_MU_FLOOR: Number = 1e-9;
478
479/// Apply the warm-start options to a configured per-worker app:
480/// always force `warm_start_init_point=yes` (the point of the warm
481/// entry), and thread the instance's resumed μ into `mu_init` unless
482/// the caller's `configure` already set one explicitly.
483fn 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
500/// Delegating wrapper that serves a [`NlpWarmStart`] from
501/// `get_starting_point` (primal always; duals when the initializer
502/// asks, i.e. under `warm_start_init_point=yes`). Any length mismatch
503/// against the wrapped problem falls back to the inner TNLP's own
504/// starting point — a per-instance cold start, mirroring the QP batch's
505/// warm-start contract. Every other method forwards unchanged.
506struct 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
610/// Solve one instance with a fresh, caller-configured application.
611///
612/// Panic-isolated: a Rust panic raised *during* the solve — a user
613/// `eval_*`/`intermediate_callback` that panics, or a backend assertion
614/// tripping on malformed structure — is caught here and reported as an
615/// [`ApplicationReturnStatus::InternalError`] row. Without this guard the
616/// panic would unwind out of the rayon `map`/`collect` (or the sequential
617/// `map`) and discard *every other instance's* result, so a single bad
618/// instance would poison the whole batch. (Solver-detected failures —
619/// infeasibility, invalid numbers, eval callbacks that *return* failure —
620/// are already reported as ordinary statuses and never reach this path.)
621fn 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    // `AssertUnwindSafe`: on a caught panic we discard `app`/`cap` without
633    // observing them, so any broken interior-mutability invariant cannot
634    // leak out — only the freshly-built `InternalError` row is returned.
635    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
656/// Solve a batch of independent NLPs **sequentially**, returning one
657/// result per input in input order. `configure` is called once per
658/// instance (receiving the instance index, so per-instance options are
659/// possible) on a fresh [`IpoptApplication`] (set options / backend /
660/// restoration there — see the module docs). Predictable and
661/// contention-free; the right choice when each instance is large
662/// enough that the linear-solver backend parallelizes internally.
663pub 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
675/// Solve a batch of independent NLPs **in parallel across instances**
676/// on rayon's global pool, returning one result per input in input
677/// order regardless of completion order. Best for many small / medium
678/// instances where cross-instance throughput beats parallelizing each
679/// factor internally.
680///
681/// Each worker owns its instance end-to-end: the `T: TNLP + Send`
682/// moves in, and the application, backend, and restoration strategy
683/// are all constructed *inside* the worker via `configure` (which must
684/// therefore be `Sync` — it is shared by reference and called once per
685/// instance, with the instance index so per-instance options are
686/// possible). For the outer-parallel / inner-serial win, have
687/// `configure` install an inner-serial backend —
688/// [`install_serial_feral_backend`] after setting options.
689pub 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
705/// Warm-started **sequential** batch — [`solve_nlp_batch`] seeded per
706/// instance from `warms`. See [`solve_nlp_batch_parallel_warm`] for
707/// the warm-start contract (option forcing, dimension-mismatch
708/// fallback).
709///
710/// # Panics
711/// Panics if `warms.len() != problems.len()`.
712pub 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
740/// Warm-started parallel batch: like [`solve_nlp_batch_parallel`] but
741/// each instance is seeded from the corresponding entry of `warms` —
742/// typically the previous step's [`NlpBatchSolution`]s for a sequence
743/// of nearby batches (receding-horizon MPC, sequential parameter
744/// continuation, B&B dives). The NLP analog of the QP batch's
745/// `solve_qp_batch_parallel_warm`.
746///
747/// Each worker forces `warm_start_init_point=yes` *after* `configure`
748/// runs (that option is the point of this entry; pair it with
749/// `mu_init` / `warm_start_target_mu` via `configure` for the full
750/// re-optimization effect), then serves the warm iterate through
751/// `get_starting_point`. A warm start only affects an instance's
752/// iteration count, not its solution; a per-instance dimension
753/// mismatch falls back to that instance's own (cold) starting point.
754///
755/// # Panics
756/// Panics if `warms.len() != problems.len()`.
757pub 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    /// `min (x0 - a)^2 + (x1 - b)^2  s.t. x0 + x1 = s` — a tiny
791    /// per-instance-parameterized equality-constrained QP with an
792    /// analytic solution: `x = (a, b) + ((s - a - b)/2) * (1, 1)`.
793    struct ShiftedQuad {
794        a: f64,
795        b: f64,
796        s: f64,
797        /// Optional variable bounds (default free).
798        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    /// A `ShiftedQuad` that panics inside `eval_f` when `boom` — stands in
906    /// for any Rust panic raised mid-solve (a buggy user callback, a backend
907    /// assertion). Used to prove panic isolation: the panicking instance must
908    /// fail only itself, not unwind the whole batch.
909    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        // gh#990 item 11: every instance carries its own wall-clock time.
978        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            // Input order: each instance's analytic optimum.
997            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            // Parallel and sequential agree (same algorithm, same
1004            // serial backend — bit-for-bit).
1005            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        // Middle instance has contradictory bounds vs. the equality
1016        // row: x0 + x1 = 10 with x <= 1 componentwise.
1017        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        // Middle instance panics inside `eval_f`; the surrounding good
1034        // instances must still solve, the batch must keep input order, and
1035        // the panicking row must surface as `InternalError` — not unwind the
1036        // whole `collect()`. The default panic hook still prints the message;
1037        // that is fine (and useful) — only the unwind is contained.
1038        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        // The sequential path is panic-isolated too (same `solve_nlp_one`).
1067        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    /// Warm-start chain (the MPC shape): solve a batch cold, perturb
1082    /// each instance's data slightly, re-solve warm-seeded from the
1083    /// previous solutions. Solutions must be correct, and the warm
1084    /// solves must not iterate more than the cold solves of the same
1085    /// perturbed instances.
1086    #[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        // Perturb each instance: shift the equality target slightly.
1093        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    /// gh#1076: the pooled wrapper answers every hook the bare feral
1122    /// backend answers. `singularity_certified` was missing and fell back
1123    /// to the trait's `false`, so on a matrix with a genuine zero pivot a
1124    /// pooled batch member told the perturbation handler the opposite of
1125    /// what a fresh backend tells it (gh#981).
1126    #[test]
1127    fn pooled_backend_forwards_singularity_certified() {
1128        use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
1129        // [[1 1 0] [1 1 0] [0 0 1]]: rank 2, one exact zero pivot.
1130        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    /// A dimension-mismatched warm entry falls back to that instance's
1174    /// own cold start instead of failing.
1175    #[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    /// The pool moves parked backends between threads (HashMap behind
1192    /// the pool's mutex), so the backend must be `Send`; the pool
1193    /// itself is shared by reference from the `Sync` configure hook.
1194    #[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    /// Pooled backends on an identical-structure batch: results match
1203    /// the fresh-backend batch within solver tolerance, and the pool
1204    /// ends up holding at most one parked solver per worker thread.
1205    #[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    /// Ragged convergence: mix trivially-easy instances with ones that
1238    /// start far away; per-instance iteration counts may differ and
1239    /// every result still lands at its own optimum in input order.
1240    #[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}