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 update_with_barrier(&mut self, mu: Number) -> bool {
188        self.inner.update_with_barrier(mu)
189    }
190
191    fn intermediate_callback(
192        &mut self,
193        stats: IterStats,
194        ip_data: &IpoptData,
195        ip_cq: &IpoptCq,
196    ) -> bool {
197        self.inner.intermediate_callback(stats, ip_data, ip_cq)
198    }
199    fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
200        self.inner.finalize_metadata(var, con)
201    }
202}
203
204/// Install an inner-serial FERAL backend on `app`, honoring any
205/// `feral_*` options already set (read the options *before* calling
206/// this if `configure` sets them — i.e. set options first, then call
207/// this). The `parallel = off` toggle is per-backend; concurrent
208/// solves on other threads are unaffected.
209pub fn install_serial_feral_backend(app: &mut IpoptApplication) {
210    let mut cfg = crate::application::feral_config_from_options(app.options());
211    cfg.parallel = Some(false);
212    app.set_linear_backend_factory(Box::new(move |_choice| {
213        Box::new(pounce_feral::FeralSolverInterface::with_config(cfg.clone()))
214    }));
215}
216
217/// Per-thread pool of serial FERAL backends for **identical-sparsity**
218/// batches (the issue-#126 "optional later optimization": reuse the
219/// symbolic analysis across instances).
220///
221/// FERAL's `Solver` keys its symbolic factorization (fill-reducing
222/// ordering + supernode structure) on a pattern fingerprint and reuses
223/// it across `factor()` calls — that is what already amortizes the
224/// symbolic cost over a single instance's IPM iterations. A fresh
225/// backend per instance throws that cache away between instances; this
226/// pool instead parks each worker thread's backend when its solve
227/// finishes and hands it to the next instance scheduled on that
228/// thread. When the next instance's KKT pattern is identical, its
229/// first factorization hits the cached symbolic; when it differs, the
230/// fingerprint mismatch triggers a fresh analysis — so pooling is
231/// *always correct*, just only *profitable* for shared-structure
232/// sweeps.
233///
234/// **Determinism caveat (why this is opt-in):** the pooled solver
235/// carries value-adjacent state across instances — most notably the
236/// MC64 scaling cache (reused only inside its validity bound) and any
237/// pivot-quality escalation a previous instance triggered. Solutions
238/// still satisfy the same tolerances, but are not guaranteed
239/// bit-identical to fresh-backend solves. The default batch entry
240/// points keep one fresh backend per instance.
241///
242/// Construct once per batch ([`Self::serial`]), share the `Arc` with
243/// each worker's `configure` via
244/// [`install_pooled_serial_feral_backend`]. Restoration-phase inner
245/// solves keep their own fresh backends. Pooled solvers (and their
246/// cached factors) free when the `Arc` drops.
247pub struct FeralBackendPool {
248    cfg: pounce_feral::FeralConfig,
249    slots: Mutex<HashMap<ThreadId, pounce_feral::FeralSolverInterface>>,
250}
251
252impl FeralBackendPool {
253    /// A pool minting inner-serial backends with `cfg` (its `parallel`
254    /// field is forced off — pooling exists for the outer-parallel /
255    /// inner-serial batch shape).
256    pub fn serial(mut cfg: pounce_feral::FeralConfig) -> Arc<Self> {
257        cfg.parallel = Some(false);
258        Arc::new(Self {
259            cfg,
260            slots: Mutex::new(HashMap::new()),
261        })
262    }
263
264    /// Take this thread's parked backend (or mint a fresh one). The
265    /// returned guard parks it back on drop, on the same thread.
266    fn acquire(self: &Arc<Self>) -> PooledFeralBackend {
267        let recycled = self
268            .slots
269            .lock()
270            .ok()
271            .and_then(|mut s| s.remove(&thread::current().id()));
272        let mut inner = recycled
273            .unwrap_or_else(|| pounce_feral::FeralSolverInterface::with_config(self.cfg.clone()));
274        // A parked solver keeps whatever its last owner set; a new owner
275        // starts from the default.
276        inner.set_reused_scaling_zero_pivot_recheck(true);
277        PooledFeralBackend {
278            inner: Some(inner),
279            pool: Arc::clone(self),
280        }
281    }
282}
283
284impl std::fmt::Debug for FeralBackendPool {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        let parked = self.slots.lock().map(|s| s.len()).unwrap_or(0);
287        f.debug_struct("FeralBackendPool")
288            .field("parked", &parked)
289            .finish_non_exhaustive()
290    }
291}
292
293/// RAII guard around a pooled [`pounce_feral::FeralSolverInterface`]:
294/// delegates the backend trait verbatim and parks the solver back into
295/// its pool slot when the solve's ownership chain drops it. If the
296/// slot is already occupied (two live backends on one thread — e.g. a
297/// nested factory call), the extra solver is simply dropped: pooling
298/// degrades to fresh-per-instance, never to shared-mutable state.
299struct PooledFeralBackend {
300    inner: Option<pounce_feral::FeralSolverInterface>,
301    pool: Arc<FeralBackendPool>,
302}
303
304impl PooledFeralBackend {
305    fn get(&mut self) -> &mut pounce_feral::FeralSolverInterface {
306        // Invariant: `inner` is `Some` from construction until drop.
307        #[allow(clippy::expect_used)]
308        self.inner.as_mut().expect("pooled backend already taken")
309    }
310}
311
312impl Drop for PooledFeralBackend {
313    fn drop(&mut self) {
314        if let (Some(solver), Ok(mut slots)) = (self.inner.take(), self.pool.slots.lock()) {
315            slots.entry(thread::current().id()).or_insert(solver);
316        }
317    }
318}
319
320impl SparseSymLinearSolverInterface for PooledFeralBackend {
321    fn initialize_structure(
322        &mut self,
323        dim: Index,
324        nonzeros: Index,
325        ia: &[Index],
326        ja: &[Index],
327    ) -> ESymSolverStatus {
328        self.get().initialize_structure(dim, nonzeros, ia, ja)
329    }
330    fn values_array_mut(&mut self) -> &mut [Number] {
331        self.get().values_array_mut()
332    }
333    fn multi_solve(
334        &mut self,
335        new_matrix: bool,
336        ia: &[Index],
337        ja: &[Index],
338        nrhs: Index,
339        rhs_vals: &mut [Number],
340        check_neg_evals: bool,
341        number_of_neg_evals: Index,
342    ) -> ESymSolverStatus {
343        self.get().multi_solve(
344            new_matrix,
345            ia,
346            ja,
347            nrhs,
348            rhs_vals,
349            check_neg_evals,
350            number_of_neg_evals,
351        )
352    }
353    fn number_of_neg_evals(&self) -> Index {
354        match &self.inner {
355            Some(s) => s.number_of_neg_evals(),
356            None => 0,
357        }
358    }
359    fn increase_quality(&mut self) -> bool {
360        self.get().increase_quality()
361    }
362    fn provides_inertia(&self) -> bool {
363        self.inner.as_ref().is_some_and(|s| s.provides_inertia())
364    }
365    // gh#1076: the two hooks below have defaults on the trait, so leaving
366    // them out compiled and silently answered `false` for every pooled
367    // solve. `singularity_certified` is what the perturbation handler reads
368    // to decide whether a post-walk-back `Singular` refutes the `δ_c`
369    // withdrawal (gh#981), so a pooled batch member took a different
370    // ladder from the same model solved on a fresh backend. A wrapper that
371    // claims to delegate verbatim has to forward every method the backend
372    // overrides, not only the required ones.
373    fn set_reused_scaling_zero_pivot_recheck(&mut self, on: bool) {
374        self.get().set_reused_scaling_zero_pivot_recheck(on);
375    }
376    fn singularity_certified(&self) -> bool {
377        self.inner
378            .as_ref()
379            .is_some_and(|s| s.singularity_certified())
380    }
381    fn multi_solve_matches_single_solve(&self, nrhs: usize) -> bool {
382        self.inner
383            .as_ref()
384            .is_some_and(|s| s.multi_solve_matches_single_solve(nrhs))
385    }
386    fn matrix_format(&self) -> EMatrixFormat {
387        match &self.inner {
388            Some(s) => s.matrix_format(),
389            None => EMatrixFormat::TripletFormat,
390        }
391    }
392    fn provides_degeneracy_detection(&self) -> bool {
393        self.inner
394            .as_ref()
395            .is_some_and(|s| s.provides_degeneracy_detection())
396    }
397    fn determine_dependent_rows(
398        &mut self,
399        n_rows: Index,
400        n_cols: Index,
401        irn: &[Index],
402        jcn: &[Index],
403        vals: &[Number],
404        c_deps: &mut Vec<Index>,
405    ) -> ESymSolverStatus {
406        self.get()
407            .determine_dependent_rows(n_rows, n_cols, irn, jcn, vals, c_deps)
408    }
409    fn factor_pattern(&self, want_values: bool) -> Option<pounce_linsol::FactorPattern> {
410        self.inner
411            .as_ref()
412            .and_then(|s| s.factor_pattern(want_values))
413    }
414}
415
416/// Install a backend factory drawing from `pool` — the
417/// identical-sparsity batch optimization (see [`FeralBackendPool`] for
418/// the reuse semantics and the determinism caveat). Call from
419/// `configure` *instead of* [`install_serial_feral_backend`].
420pub fn install_pooled_serial_feral_backend(
421    app: &mut IpoptApplication,
422    pool: &Arc<FeralBackendPool>,
423) {
424    let pool = Arc::clone(pool);
425    app.set_linear_backend_factory(Box::new(move |_choice| Box::new(pool.acquire())));
426}
427
428/// Per-instance warm-start iterate for [`solve_nlp_batch_parallel_warm`]:
429/// primal point plus the three dual vectors the IPM's warm-start
430/// initializer consumes via `TNLP::get_starting_point`. Build one from
431/// a previous batch's [`NlpBatchSolution`] (the `From` impl) for MPC /
432/// sequential-chaining workloads.
433#[derive(Debug, Clone, Default)]
434pub struct NlpWarmStart {
435    pub x: Vec<Number>,
436    /// Constraint multipliers λ.
437    pub lambda: Vec<Number>,
438    /// Lower / upper bound multipliers.
439    pub z_l: Vec<Number>,
440    pub z_u: Vec<Number>,
441    /// Barrier parameter μ to resume from — typically the previous
442    /// solve's final μ ([`SolveStatistics::final_mu`]). When `Some`,
443    /// the warm batch sets `mu_init` to it (floored at `1e-9`, and
444    /// only if the caller's `configure` didn't set `mu_init`
445    /// explicitly), so the IPM continues near the converged barrier
446    /// instead of recentering at the cold default `0.1` — without
447    /// this, a warm start from a near-optimal point can *gain*
448    /// iterations walking back out to the central path.
449    pub mu: Option<Number>,
450}
451
452impl From<&NlpBatchSolution> for NlpWarmStart {
453    fn from(sol: &NlpBatchSolution) -> Self {
454        Self {
455            x: sol.x.clone(),
456            lambda: sol.lambda.clone(),
457            z_l: sol.z_l.clone(),
458            z_u: sol.z_u.clone(),
459            mu: None,
460        }
461    }
462}
463
464/// Build a warm start from a full batch result: iterate + duals from
465/// the captured solution, μ from the statistics. An instance whose
466/// solve produced no solution yields an empty warm start, which the
467/// next solve treats as a cold start (dimension-mismatch fallback).
468impl From<&NlpBatchResult> for NlpWarmStart {
469    fn from(r: &NlpBatchResult) -> Self {
470        match &r.solution {
471            Some(sol) => Self {
472                mu: Some(r.stats.final_mu),
473                ..Self::from(sol)
474            },
475            None => Self::default(),
476        }
477    }
478}
479
480/// Floor for warm-start `mu_init` threading (see [`NlpWarmStart::mu`]).
481const WARM_MU_FLOOR: Number = 1e-9;
482
483/// Apply the warm-start options to a configured per-worker app:
484/// always force `warm_start_init_point=yes` (the point of the warm
485/// entry), and thread the instance's resumed μ into `mu_init` unless
486/// the caller's `configure` already set one explicitly.
487fn apply_warm_options(app: &mut IpoptApplication, mu: Option<Number>) {
488    let _ = app
489        .options_mut()
490        .set_string_value("warm_start_init_point", "yes", true, false);
491    if let Some(mu) = mu {
492        let user_set_mu = matches!(
493            app.options().get_numeric_value("mu_init", ""),
494            Ok((_, true))
495        );
496        if !user_set_mu {
497            let _ =
498                app.options_mut()
499                    .set_numeric_value("mu_init", mu.max(WARM_MU_FLOOR), true, false);
500        }
501    }
502}
503
504/// Delegating wrapper that serves a [`NlpWarmStart`] from
505/// `get_starting_point` (primal always; duals when the initializer
506/// asks, i.e. under `warm_start_init_point=yes`). Any length mismatch
507/// against the wrapped problem falls back to the inner TNLP's own
508/// starting point — a per-instance cold start, mirroring the QP batch's
509/// warm-start contract. Every other method forwards unchanged.
510struct WarmStartTnlp<T: TNLP> {
511    inner: T,
512    warm: NlpWarmStart,
513}
514
515impl<T: TNLP> TNLP for WarmStartTnlp<T> {
516    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
517        self.inner.get_nlp_info()
518    }
519    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
520        self.inner.get_bounds_info(b)
521    }
522    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
523        let dims_ok = self.warm.x.len() == sp.x.len()
524            && (!sp.init_lambda || self.warm.lambda.len() == sp.lambda.len())
525            && (!sp.init_z
526                || (self.warm.z_l.len() == sp.z_l.len() && self.warm.z_u.len() == sp.z_u.len()));
527        if !dims_ok {
528            return self.inner.get_starting_point(sp);
529        }
530        if sp.init_x {
531            sp.x.copy_from_slice(&self.warm.x);
532        }
533        if sp.init_z {
534            sp.z_l.copy_from_slice(&self.warm.z_l);
535            sp.z_u.copy_from_slice(&self.warm.z_u);
536        }
537        if sp.init_lambda {
538            sp.lambda.copy_from_slice(&self.warm.lambda);
539        }
540        true
541    }
542    fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
543        self.inner.eval_f(x, new_x)
544    }
545    fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
546        self.inner.eval_grad_f(x, new_x, grad_f)
547    }
548    fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
549        self.inner.eval_g(x, new_x, g)
550    }
551    fn eval_jac_g(&mut self, x: Option<&[Number]>, new_x: bool, mode: SparsityRequest<'_>) -> bool {
552        self.inner.eval_jac_g(x, new_x, mode)
553    }
554    fn eval_h(
555        &mut self,
556        x: Option<&[Number]>,
557        new_x: bool,
558        obj_factor: Number,
559        lambda: Option<&[Number]>,
560        new_lambda: bool,
561        mode: SparsityRequest<'_>,
562    ) -> bool {
563        self.inner
564            .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
565    }
566    fn finalize_solution(&mut self, sol: Solution<'_>, ip_data: &IpoptData, ip_cq: &IpoptCq) {
567        self.inner.finalize_solution(sol, ip_data, ip_cq)
568    }
569    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
570        self.inner.get_var_con_metadata(var, con)
571    }
572    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
573        self.inner.get_scaling_parameters(req)
574    }
575    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
576        self.inner.get_variables_linearity(types)
577    }
578    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
579        self.inner.get_objective_variables_linearity(types)
580    }
581    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
582        self.inner.get_constraints_linearity(types)
583    }
584    fn get_number_of_nonlinear_variables(&mut self) -> pounce_common::types::Index {
585        self.inner.get_number_of_nonlinear_variables()
586    }
587    fn get_list_of_nonlinear_variables(
588        &mut self,
589        pos_nonlin_vars: &mut [pounce_common::types::Index],
590    ) -> bool {
591        self.inner.get_list_of_nonlinear_variables(pos_nonlin_vars)
592    }
593    fn wants_intermediate_callback(&self) -> bool {
594        self.inner.wants_intermediate_callback()
595    }
596
597    fn wants_current_iterate(&self) -> bool {
598        self.inner.wants_current_iterate()
599    }
600
601    fn update_with_barrier(&mut self, mu: Number) -> bool {
602        self.inner.update_with_barrier(mu)
603    }
604
605    fn intermediate_callback(
606        &mut self,
607        stats: IterStats,
608        ip_data: &IpoptData,
609        ip_cq: &IpoptCq,
610    ) -> bool {
611        self.inner.intermediate_callback(stats, ip_data, ip_cq)
612    }
613    fn finalize_metadata(&mut self, var: &MetaData, con: &MetaData) {
614        self.inner.finalize_metadata(var, con)
615    }
616}
617
618/// Solve one instance with a fresh, caller-configured application.
619///
620/// Panic-isolated: a Rust panic raised *during* the solve — a user
621/// `eval_*`/`intermediate_callback` that panics, or a backend assertion
622/// tripping on malformed structure — is caught here and reported as an
623/// [`ApplicationReturnStatus::InternalError`] row. Without this guard the
624/// panic would unwind out of the rayon `map`/`collect` (or the sequential
625/// `map`) and discard *every other instance's* result, so a single bad
626/// instance would poison the whole batch. (Solver-detected failures —
627/// infeasibility, invalid numbers, eval callbacks that *return* failure —
628/// are already reported as ordinary statuses and never reach this path.)
629fn solve_nlp_one<T, C>(index: usize, tnlp: T, configure: &mut C) -> NlpBatchResult
630where
631    T: TNLP + 'static,
632    C: FnMut(usize, &mut IpoptApplication),
633{
634    let mut app = IpoptApplication::new();
635    configure(index, &mut app);
636    let cap = Rc::new(RefCell::new(CaptureTnlp {
637        inner: tnlp,
638        captured: None,
639    }));
640    // `AssertUnwindSafe`: on a caught panic we discard `app`/`cap` without
641    // observing them, so any broken interior-mutability invariant cannot
642    // leak out — only the freshly-built `InternalError` row is returned.
643    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
644        let t0 = std::time::Instant::now();
645        let status = app.optimize_tnlp(Rc::clone(&cap) as Rc<RefCell<dyn TNLP>>);
646        let wall_time = t0.elapsed().as_secs_f64();
647        let stats = app.statistics();
648        let solution = cap.borrow_mut().captured.take();
649        NlpBatchResult {
650            status,
651            solution,
652            stats,
653            wall_time,
654        }
655    }));
656    outcome.unwrap_or_else(|_| NlpBatchResult {
657        status: ApplicationReturnStatus::InternalError,
658        solution: None,
659        stats: SolveStatistics::default(),
660        wall_time: 0.0,
661    })
662}
663
664/// Solve a batch of independent NLPs **sequentially**, returning one
665/// result per input in input order. `configure` is called once per
666/// instance (receiving the instance index, so per-instance options are
667/// possible) on a fresh [`IpoptApplication`] (set options / backend /
668/// restoration there — see the module docs). Predictable and
669/// contention-free; the right choice when each instance is large
670/// enough that the linear-solver backend parallelizes internally.
671pub fn solve_nlp_batch<T, C>(problems: Vec<T>, mut configure: C) -> Vec<NlpBatchResult>
672where
673    T: TNLP + 'static,
674    C: FnMut(usize, &mut IpoptApplication),
675{
676    problems
677        .into_iter()
678        .enumerate()
679        .map(|(i, t)| solve_nlp_one(i, t, &mut configure))
680        .collect()
681}
682
683/// Solve a batch of independent NLPs **in parallel across instances**
684/// on rayon's global pool, returning one result per input in input
685/// order regardless of completion order. Best for many small / medium
686/// instances where cross-instance throughput beats parallelizing each
687/// factor internally.
688///
689/// Each worker owns its instance end-to-end: the `T: TNLP + Send`
690/// moves in, and the application, backend, and restoration strategy
691/// are all constructed *inside* the worker via `configure` (which must
692/// therefore be `Sync` — it is shared by reference and called once per
693/// instance, with the instance index so per-instance options are
694/// possible). For the outer-parallel / inner-serial win, have
695/// `configure` install an inner-serial backend —
696/// [`install_serial_feral_backend`] after setting options.
697pub fn solve_nlp_batch_parallel<T, C>(problems: Vec<T>, configure: C) -> Vec<NlpBatchResult>
698where
699    T: TNLP + Send + 'static,
700    C: Fn(usize, &mut IpoptApplication) + Sync,
701{
702    problems
703        .into_par_iter()
704        .enumerate()
705        .map(|(i, t)| {
706            solve_nlp_one(i, t, &mut |i: usize, app: &mut IpoptApplication| {
707                configure(i, app)
708            })
709        })
710        .collect()
711}
712
713/// Warm-started **sequential** batch — [`solve_nlp_batch`] seeded per
714/// instance from `warms`. See [`solve_nlp_batch_parallel_warm`] for
715/// the warm-start contract (option forcing, dimension-mismatch
716/// fallback).
717///
718/// # Panics
719/// Panics if `warms.len() != problems.len()`.
720pub fn solve_nlp_batch_warm<T, C>(
721    problems: Vec<T>,
722    warms: Vec<NlpWarmStart>,
723    mut configure: C,
724) -> Vec<NlpBatchResult>
725where
726    T: TNLP + 'static,
727    C: FnMut(usize, &mut IpoptApplication),
728{
729    assert_eq!(
730        warms.len(),
731        problems.len(),
732        "warms.len() ({}) must equal problems.len() ({})",
733        warms.len(),
734        problems.len()
735    );
736    let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
737    let wrapped: Vec<WarmStartTnlp<T>> = problems
738        .into_iter()
739        .zip(warms)
740        .map(|(inner, warm)| WarmStartTnlp { inner, warm })
741        .collect();
742    solve_nlp_batch(wrapped, |i, app: &mut IpoptApplication| {
743        configure(i, app);
744        apply_warm_options(app, mus[i]);
745    })
746}
747
748/// Warm-started parallel batch: like [`solve_nlp_batch_parallel`] but
749/// each instance is seeded from the corresponding entry of `warms` —
750/// typically the previous step's [`NlpBatchSolution`]s for a sequence
751/// of nearby batches (receding-horizon MPC, sequential parameter
752/// continuation, B&B dives). The NLP analog of the QP batch's
753/// `solve_qp_batch_parallel_warm`.
754///
755/// Each worker forces `warm_start_init_point=yes` *after* `configure`
756/// runs (that option is the point of this entry; pair it with
757/// `mu_init` / `warm_start_target_mu` via `configure` for the full
758/// re-optimization effect), then serves the warm iterate through
759/// `get_starting_point`. A warm start only affects an instance's
760/// iteration count, not its solution; a per-instance dimension
761/// mismatch falls back to that instance's own (cold) starting point.
762///
763/// # Panics
764/// Panics if `warms.len() != problems.len()`.
765pub fn solve_nlp_batch_parallel_warm<T, C>(
766    problems: Vec<T>,
767    warms: Vec<NlpWarmStart>,
768    configure: C,
769) -> Vec<NlpBatchResult>
770where
771    T: TNLP + Send + 'static,
772    C: Fn(usize, &mut IpoptApplication) + Sync,
773{
774    assert_eq!(
775        warms.len(),
776        problems.len(),
777        "warms.len() ({}) must equal problems.len() ({})",
778        warms.len(),
779        problems.len()
780    );
781    let mus: Vec<Option<Number>> = warms.iter().map(|w| w.mu).collect();
782    let wrapped: Vec<WarmStartTnlp<T>> = problems
783        .into_iter()
784        .zip(warms)
785        .map(|(inner, warm)| WarmStartTnlp { inner, warm })
786        .collect();
787    solve_nlp_batch_parallel(wrapped, |i, app: &mut IpoptApplication| {
788        configure(i, app);
789        apply_warm_options(app, mus[i]);
790    })
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use pounce_nlp::tnlp::IndexStyle;
797
798    /// `min (x0 - a)^2 + (x1 - b)^2  s.t. x0 + x1 = s` — a tiny
799    /// per-instance-parameterized equality-constrained QP with an
800    /// analytic solution: `x = (a, b) + ((s - a - b)/2) * (1, 1)`.
801    struct ShiftedQuad {
802        a: f64,
803        b: f64,
804        s: f64,
805        /// Optional variable bounds (default free).
806        x_l: [f64; 2],
807        x_u: [f64; 2],
808    }
809
810    impl ShiftedQuad {
811        fn new(a: f64, b: f64, s: f64) -> Self {
812            Self {
813                a,
814                b,
815                s,
816                x_l: [-1e19; 2],
817                x_u: [1e19; 2],
818            }
819        }
820        fn expected(&self) -> [f64; 2] {
821            let t = (self.s - self.a - self.b) / 2.0;
822            [self.a + t, self.b + t]
823        }
824    }
825
826    impl TNLP for ShiftedQuad {
827        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
828            Some(NlpInfo {
829                n: 2,
830                m: 1,
831                nnz_jac_g: 2,
832                nnz_h_lag: 2,
833                index_style: IndexStyle::C,
834            })
835        }
836        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
837            b.x_l.copy_from_slice(&self.x_l);
838            b.x_u.copy_from_slice(&self.x_u);
839            b.g_l[0] = self.s;
840            b.g_u[0] = self.s;
841            true
842        }
843        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
844            sp.x[0] = 0.0;
845            sp.x[1] = 0.0;
846            true
847        }
848        fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
849            Some((x[0] - self.a).powi(2) + (x[1] - self.b).powi(2))
850        }
851        fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
852            grad_f[0] = 2.0 * (x[0] - self.a);
853            grad_f[1] = 2.0 * (x[1] - self.b);
854            true
855        }
856        fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
857            g[0] = x[0] + x[1];
858            true
859        }
860        fn eval_jac_g(
861            &mut self,
862            _x: Option<&[Number]>,
863            _new_x: bool,
864            mode: SparsityRequest<'_>,
865        ) -> bool {
866            match mode {
867                SparsityRequest::Structure { irow, jcol } => {
868                    irow.copy_from_slice(&[0, 0]);
869                    jcol.copy_from_slice(&[0, 1]);
870                }
871                SparsityRequest::Values { values } => {
872                    values.copy_from_slice(&[1.0, 1.0]);
873                }
874            }
875            true
876        }
877        fn eval_h(
878            &mut self,
879            _x: Option<&[Number]>,
880            _new_x: bool,
881            obj_factor: Number,
882            _lambda: Option<&[Number]>,
883            _new_lambda: bool,
884            mode: SparsityRequest<'_>,
885        ) -> bool {
886            match mode {
887                SparsityRequest::Structure { irow, jcol } => {
888                    irow.copy_from_slice(&[0, 1]);
889                    jcol.copy_from_slice(&[0, 1]);
890                }
891                SparsityRequest::Values { values } => {
892                    values.copy_from_slice(&[2.0 * obj_factor, 2.0 * obj_factor]);
893                }
894            }
895            true
896        }
897        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
898    }
899
900    fn configure(_i: usize, app: &mut IpoptApplication) {
901        let _ = app
902            .options_mut()
903            .set_integer_value("print_level", 0, true, false);
904        install_serial_feral_backend(app);
905    }
906
907    fn batch(k: usize) -> Vec<ShiftedQuad> {
908        (0..k)
909            .map(|i| ShiftedQuad::new(1.0 + i as f64, 2.0, 1.0 + (i % 3) as f64))
910            .collect()
911    }
912
913    /// A `ShiftedQuad` that panics inside `eval_f` when `boom` — stands in
914    /// for any Rust panic raised mid-solve (a buggy user callback, a backend
915    /// assertion). Used to prove panic isolation: the panicking instance must
916    /// fail only itself, not unwind the whole batch.
917    struct BoomQuad {
918        inner: ShiftedQuad,
919        boom: bool,
920    }
921
922    impl TNLP for BoomQuad {
923        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
924            self.inner.get_nlp_info()
925        }
926        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
927            self.inner.get_bounds_info(b)
928        }
929        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
930            self.inner.get_starting_point(sp)
931        }
932        fn eval_f(&mut self, x: &[Number], new_x: bool) -> Option<Number> {
933            if self.boom {
934                panic!("boom: simulated mid-solve panic in eval_f");
935            }
936            self.inner.eval_f(x, new_x)
937        }
938        fn eval_grad_f(&mut self, x: &[Number], new_x: bool, grad_f: &mut [Number]) -> bool {
939            self.inner.eval_grad_f(x, new_x, grad_f)
940        }
941        fn eval_g(&mut self, x: &[Number], new_x: bool, g: &mut [Number]) -> bool {
942            self.inner.eval_g(x, new_x, g)
943        }
944        fn eval_jac_g(
945            &mut self,
946            x: Option<&[Number]>,
947            new_x: bool,
948            mode: SparsityRequest<'_>,
949        ) -> bool {
950            self.inner.eval_jac_g(x, new_x, mode)
951        }
952        fn eval_h(
953            &mut self,
954            x: Option<&[Number]>,
955            new_x: bool,
956            obj_factor: Number,
957            lambda: Option<&[Number]>,
958            new_lambda: bool,
959            mode: SparsityRequest<'_>,
960        ) -> bool {
961            self.inner
962                .eval_h(x, new_x, obj_factor, lambda, new_lambda, mode)
963        }
964        fn finalize_solution(&mut self, sol: Solution<'_>, d: &IpoptData, q: &IpoptCq) {
965            self.inner.finalize_solution(sol, d, q)
966        }
967    }
968
969    #[test]
970    fn empty_batch_returns_empty() {
971        let out = solve_nlp_batch_parallel(Vec::<ShiftedQuad>::new(), configure);
972        assert!(out.is_empty());
973    }
974
975    #[test]
976    fn single_element_batch_solves() {
977        let probs = batch(1);
978        let expected = probs[0].expected();
979        let out = solve_nlp_batch_parallel(probs, configure);
980        assert_eq!(out.len(), 1);
981        assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
982        let sol = out[0].solution.as_ref().expect("solution captured");
983        assert!((sol.x[0] - expected[0]).abs() < 1e-6);
984        assert!((sol.x[1] - expected[1]).abs() < 1e-6);
985        // gh#990 item 11: every instance carries its own wall-clock time.
986        assert!(out[0].wall_time > 0.0 && out[0].wall_time.is_finite());
987    }
988
989    #[test]
990    fn parallel_results_in_input_order_and_match_sequential() {
991        let k = 8;
992        let expected: Vec<[f64; 2]> = batch(k).iter().map(|p| p.expected()).collect();
993        let par = solve_nlp_batch_parallel(batch(k), configure);
994        let seq = solve_nlp_batch(batch(k), configure);
995        assert_eq!(par.len(), k);
996        for i in 0..k {
997            assert_eq!(
998                par[i].status,
999                ApplicationReturnStatus::SolveSucceeded,
1000                "instance {i}"
1001            );
1002            let ps = par[i].solution.as_ref().expect("parallel solution");
1003            let ss = seq[i].solution.as_ref().expect("sequential solution");
1004            // Input order: each instance's analytic optimum.
1005            assert!(
1006                (ps.x[0] - expected[i][0]).abs() < 1e-6 && (ps.x[1] - expected[i][1]).abs() < 1e-6,
1007                "instance {i}: got {:?}, expected {:?}",
1008                ps.x,
1009                expected[i]
1010            );
1011            // Parallel and sequential agree (same algorithm, same
1012            // serial backend — bit-for-bit).
1013            assert_eq!(ps.x, ss.x, "instance {i}");
1014            assert_eq!(
1015                par[i].stats.iteration_count, seq[i].stats.iteration_count,
1016                "instance {i}"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn infeasible_instance_mixed_in_does_not_poison_batch() {
1023        // Middle instance has contradictory bounds vs. the equality
1024        // row: x0 + x1 = 10 with x <= 1 componentwise.
1025        let mut probs = batch(3);
1026        probs[1].s = 10.0;
1027        probs[1].x_u = [1.0; 2];
1028        let out = solve_nlp_batch_parallel(probs, configure);
1029        assert_eq!(out.len(), 3);
1030        assert_eq!(out[0].status, ApplicationReturnStatus::SolveSucceeded);
1031        assert_eq!(out[2].status, ApplicationReturnStatus::SolveSucceeded);
1032        assert_ne!(
1033            out[1].status,
1034            ApplicationReturnStatus::SolveSucceeded,
1035            "infeasible instance must not report success"
1036        );
1037    }
1038
1039    #[test]
1040    fn panicking_instance_does_not_poison_batch() {
1041        // Middle instance panics inside `eval_f`; the surrounding good
1042        // instances must still solve, the batch must keep input order, and
1043        // the panicking row must surface as `InternalError` — not unwind the
1044        // whole `collect()`. The default panic hook still prints the message;
1045        // that is fine (and useful) — only the unwind is contained.
1046        let good = batch(3);
1047        let expected: Vec<[f64; 2]> = good.iter().map(|p| p.expected()).collect();
1048        let probs: Vec<BoomQuad> = good
1049            .into_iter()
1050            .enumerate()
1051            .map(|(i, inner)| BoomQuad {
1052                inner,
1053                boom: i == 1,
1054            })
1055            .collect();
1056        let out = solve_nlp_batch_parallel(probs, configure);
1057        assert_eq!(out.len(), 3);
1058        assert_eq!(out[1].status, ApplicationReturnStatus::InternalError);
1059        assert!(
1060            out[1].solution.is_none(),
1061            "a panicked instance carries no captured solution"
1062        );
1063        for i in [0, 2] {
1064            assert_eq!(out[i].status, ApplicationReturnStatus::SolveSucceeded);
1065            let sol = out[i].solution.as_ref().expect("solution");
1066            assert!(
1067                (sol.x[0] - expected[i][0]).abs() < 1e-6
1068                    && (sol.x[1] - expected[i][1]).abs() < 1e-6,
1069                "instance {i}: got {:?}, expected {:?}",
1070                sol.x,
1071                expected[i]
1072            );
1073        }
1074        // The sequential path is panic-isolated too (same `solve_nlp_one`).
1075        let probs_seq: Vec<BoomQuad> = batch(3)
1076            .into_iter()
1077            .enumerate()
1078            .map(|(i, inner)| BoomQuad {
1079                inner,
1080                boom: i == 1,
1081            })
1082            .collect();
1083        let seq = solve_nlp_batch(probs_seq, configure);
1084        assert_eq!(seq[1].status, ApplicationReturnStatus::InternalError);
1085        assert_eq!(seq[0].status, ApplicationReturnStatus::SolveSucceeded);
1086        assert_eq!(seq[2].status, ApplicationReturnStatus::SolveSucceeded);
1087    }
1088
1089    /// Warm-start chain (the MPC shape): solve a batch cold, perturb
1090    /// each instance's data slightly, re-solve warm-seeded from the
1091    /// previous solutions. Solutions must be correct, and the warm
1092    /// solves must not iterate more than the cold solves of the same
1093    /// perturbed instances.
1094    #[test]
1095    fn warm_started_batch_chains() {
1096        let k = 4;
1097        let cold = solve_nlp_batch_parallel(batch(k), configure);
1098        let warms: Vec<NlpWarmStart> = cold.iter().map(NlpWarmStart::from).collect();
1099
1100        // Perturb each instance: shift the equality target slightly.
1101        let perturbed = || -> Vec<ShiftedQuad> {
1102            batch(k)
1103                .into_iter()
1104                .map(|mut p| {
1105                    p.s += 0.01;
1106                    p
1107                })
1108                .collect()
1109        };
1110        let warm = solve_nlp_batch_parallel_warm(perturbed(), warms, configure);
1111        let cold2 = solve_nlp_batch_parallel(perturbed(), configure);
1112        for i in 0..k {
1113            assert_eq!(warm[i].status, ApplicationReturnStatus::SolveSucceeded);
1114            let expect = perturbed()[i].expected();
1115            let sol = warm[i].solution.as_ref().expect("warm solution");
1116            assert!(
1117                (sol.x[0] - expect[0]).abs() < 1e-5 && (sol.x[1] - expect[1]).abs() < 1e-5,
1118                "instance {i}: warm solve must reach the perturbed optimum"
1119            );
1120            assert!(
1121                warm[i].stats.iteration_count <= cold2[i].stats.iteration_count,
1122                "instance {i}: warm start took {} iters vs cold {}",
1123                warm[i].stats.iteration_count,
1124                cold2[i].stats.iteration_count
1125            );
1126        }
1127    }
1128
1129    /// gh#1076: the pooled wrapper answers every hook the bare feral
1130    /// backend answers. `singularity_certified` was missing and fell back
1131    /// to the trait's `false`, so on a matrix with a genuine zero pivot a
1132    /// pooled batch member told the perturbation handler the opposite of
1133    /// what a fresh backend tells it (gh#981).
1134    #[test]
1135    fn pooled_backend_forwards_singularity_certified() {
1136        use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
1137        // [[1 1 0] [1 1 0] [0 0 1]]: rank 2, one exact zero pivot.
1138        let ia: [Index; 4] = [1, 2, 2, 3];
1139        let ja: [Index; 4] = [1, 1, 2, 3];
1140        let vals = [1.0, 1.0, 1.0, 1.0];
1141        fn factor(
1142            b: &mut dyn SparseSymLinearSolverInterface,
1143            ia: &[Index],
1144            ja: &[Index],
1145            v: &[f64],
1146        ) {
1147            assert_eq!(
1148                b.initialize_structure(3, 4, ia, ja),
1149                pounce_linsol::ESymSolverStatus::Success
1150            );
1151            b.values_array_mut().copy_from_slice(v);
1152            let mut rhs = [1.0, 1.0, 1.0];
1153            assert_eq!(
1154                b.multi_solve(true, ia, ja, 1, &mut rhs, true, 1),
1155                pounce_linsol::ESymSolverStatus::Singular
1156            );
1157        }
1158        let cfg = pounce_feral::FeralConfig {
1159            parallel: Some(false),
1160            ..pounce_feral::FeralConfig::default()
1161        };
1162        let mut fresh = pounce_feral::FeralSolverInterface::with_config(cfg.clone());
1163        factor(&mut fresh, &ia, &ja, &vals);
1164        assert!(
1165            fresh.singularity_certified(),
1166            "the bare backend must certify an exact zero pivot, or this test proves nothing"
1167        );
1168        let pool = FeralBackendPool::serial(cfg);
1169        let mut pooled = pool.acquire();
1170        factor(&mut pooled, &ia, &ja, &vals);
1171        assert_eq!(
1172            pooled.singularity_certified(),
1173            fresh.singularity_certified()
1174        );
1175        assert_eq!(
1176            pooled.multi_solve_matches_single_solve(1),
1177            fresh.multi_solve_matches_single_solve(1)
1178        );
1179    }
1180
1181    /// A dimension-mismatched warm entry falls back to that instance's
1182    /// own cold start instead of failing.
1183    #[test]
1184    fn warm_start_dimension_mismatch_falls_back_cold() {
1185        let probs = batch(2);
1186        let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1187        let warms = vec![NlpWarmStart::default(), NlpWarmStart::default()];
1188        let out = solve_nlp_batch_parallel_warm(probs, warms, configure);
1189        for (i, r) in out.iter().enumerate() {
1190            assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1191            let sol = r.solution.as_ref().expect("solution");
1192            assert!(
1193                (sol.x[0] - expected[i][0]).abs() < 1e-6
1194                    && (sol.x[1] - expected[i][1]).abs() < 1e-6
1195            );
1196        }
1197    }
1198
1199    /// The pool moves parked backends between threads (HashMap behind
1200    /// the pool's mutex), so the backend must be `Send`; the pool
1201    /// itself is shared by reference from the `Sync` configure hook.
1202    #[test]
1203    fn backend_pool_is_send_sync() {
1204        fn assert_send_sync<T: Send + Sync>() {}
1205        assert_send_sync::<FeralBackendPool>();
1206        fn assert_send<T: Send>() {}
1207        assert_send::<pounce_feral::FeralSolverInterface>();
1208    }
1209
1210    /// Pooled backends on an identical-structure batch: results match
1211    /// the fresh-backend batch within solver tolerance, and the pool
1212    /// ends up holding at most one parked solver per worker thread.
1213    #[test]
1214    fn pooled_backends_match_fresh_on_identical_structure() {
1215        let k = 8;
1216        let fresh = solve_nlp_batch_parallel(batch(k), configure);
1217
1218        let pool = FeralBackendPool::serial(pounce_feral::FeralConfig::default());
1219        let pool_for_cfg = Arc::clone(&pool);
1220        let pooled = solve_nlp_batch_parallel(batch(k), move |_i, app| {
1221            let _ = app
1222                .options_mut()
1223                .set_integer_value("print_level", 0, true, false);
1224            install_pooled_serial_feral_backend(app, &pool_for_cfg);
1225        });
1226        assert!(
1227            pool.slots.lock().map(|s| !s.is_empty()).unwrap_or(false),
1228            "at least one worker must have parked its backend"
1229        );
1230        for i in 0..k {
1231            assert_eq!(pooled[i].status, ApplicationReturnStatus::SolveSucceeded);
1232            let pf = fresh[i].solution.as_ref().expect("fresh");
1233            let pp = pooled[i].solution.as_ref().expect("pooled");
1234            for j in 0..2 {
1235                assert!(
1236                    (pf.x[j] - pp.x[j]).abs() < 1e-6,
1237                    "instance {i} x[{j}]: pooled {} vs fresh {}",
1238                    pp.x[j],
1239                    pf.x[j]
1240                );
1241            }
1242        }
1243    }
1244
1245    /// Ragged convergence: mix trivially-easy instances with ones that
1246    /// start far away; per-instance iteration counts may differ and
1247    /// every result still lands at its own optimum in input order.
1248    #[test]
1249    fn ragged_iteration_counts_keep_order() {
1250        let probs: Vec<ShiftedQuad> = (0..6)
1251            .map(|i| ShiftedQuad::new(10f64.powi(i - 3), 2.0, 1.0))
1252            .collect();
1253        let expected: Vec<[f64; 2]> = probs.iter().map(|p| p.expected()).collect();
1254        let out = solve_nlp_batch_parallel(probs, configure);
1255        for (i, r) in out.iter().enumerate() {
1256            assert_eq!(r.status, ApplicationReturnStatus::SolveSucceeded);
1257            let sol = r.solution.as_ref().expect("solution");
1258            assert!(
1259                (sol.x[0] - expected[i][0]).abs() < 1e-5
1260                    && (sol.x[1] - expected[i][1]).abs() < 1e-5,
1261                "instance {i}: got {:?}, expected {:?}",
1262                sol.x,
1263                expected[i]
1264            );
1265        }
1266    }
1267}