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