Skip to main content

otspot_core/
options.rs

1//! Solver configuration parameters.
2//!
3//! [`SolverOptions`] controls simplex and IPM solver behaviour: tolerances,
4//! iteration limits, refactorisation frequency, and algorithm selection.
5//!
6//! ## Solver-specific options
7//!
8//! IPM-specific parameters live in [`IpmOptions`], accessed via
9//! [`SolverOptions::ipm`].
10
11use crate::tolerances::*;
12use std::sync::{atomic::AtomicBool, Arc};
13
14use std::time::Instant;
15
16// ---- Error type -------------------------------------------------------
17
18/// Error returned when option values fail validation.
19///
20/// Produced by [`IpmOptions::validate`] and [`SolverOptions::validate`], and
21/// by builder methods (`with_*`) that validate on assignment.
22#[derive(Debug, Clone, PartialEq)]
23pub struct OptionsError {
24    /// Name of the offending field (e.g. `"ipm.eps"`).
25    pub field: &'static str,
26    /// Human-readable rejection reason.
27    pub reason: &'static str,
28}
29
30impl std::fmt::Display for OptionsError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "invalid option `{}`: {}", self.field, self.reason)
33    }
34}
35
36impl std::error::Error for OptionsError {}
37
38// ---- Enum / simple struct types ---------------------------------------
39
40/// Dual simplex leaving (depart) strategy.
41///
42/// `SteepestEdge`: Forrest-Goldfarb 1992 Dual Steepest Edge (default).
43/// Maintains weight γ_i = ||(B^{-1})_{i,:}||² and maximises
44/// score = x_B\[i\]² / γ_i.  Typical 3-10× speed-up (HiGHS/CPLEX) at the cost
45/// of one extra FTRAN per iteration.
46///
47/// `MostInfeasible`: select the most negative x_B\[i\] (Dantzig rule).
48/// Stable but inflates iteration count on large problems.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum DualPricing {
52    #[default]
53    SteepestEdge,
54    MostInfeasible,
55}
56
57/// Simplex algorithm selection.
58#[non_exhaustive]
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum SimplexMethod {
61    /// Auto-select based on warm-start availability.
62    #[default]
63    Auto,
64    /// Force Primal Simplex.
65    Primal,
66    /// Force Dual Simplex.
67    Dual,
68    /// Production-quality Dual Simplex (`dual_advanced` module).
69    DualAdvanced,
70}
71
72/// Basis information for warm-starting simplex.
73///
74/// Carries basis indices and primal values from a previous solve. Used as the
75/// initial basis for Dual Simplex in SQP integration.
76#[derive(Debug, Clone)]
77pub struct WarmStartBasis {
78    /// Basis variable indices (standard-form column numbers, length = m).
79    pub basis: Vec<usize>,
80    /// Basis variable values x_B (length = m). Stale values are acceptable;
81    /// they are recomputed from the new RHS on warm-start entry.
82    pub x_b: Vec<f64>,
83}
84
85/// QP IP-PMM interior-point warm-start data.
86///
87/// Passes the optimal (x, y, μ) from a parent B&B node as the starting point
88/// on the central path for the child node.  LP warm-start uses basis indices
89/// ([`WarmStartBasis`]); QP warm-start uses a central-path point.
90///
91/// Convention:
92/// - `x`: length = n (primal)
93/// - `y`: length = m (dual, user sign convention; Ge constraints inverted internally)
94/// - `mu`: barrier parameter ≈ sᵀy / m_ineq of the parent final iterate
95///
96/// Interior corrections (μ floor / x bound margin / y positivity) are applied
97/// on entry so boundary or zero values are safe to pass.
98#[derive(Debug, Clone)]
99pub struct QpWarmStart {
100    pub x: Vec<f64>,
101    pub y: Vec<f64>,
102    pub mu: f64,
103}
104
105/// Extended LP warm-start.
106///
107/// Superset of [`WarmStartBasis`]: accepts (x, y, basis) from an external
108/// solver and lands simplex at that point.  Takes priority over `warm_start`.
109///
110/// Convention:
111/// - `basis`: length = m_ext (standard-form rows), each value < n_total.
112///   Size mismatch: logged and dropped (not silently ignored).
113/// - `x_orig`: length = problem.num_vars (original variable space)
114/// - `y_orig`: length = problem.num_constraints (original constraint space, user sign)
115#[derive(Debug, Clone)]
116pub struct LpWarmStart {
117    pub basis: Vec<usize>,
118    pub x_orig: Option<Vec<f64>>,
119    pub y_orig: Option<Vec<f64>>,
120}
121
122/// Multi-start sampling strategy.
123///
124/// IPM converges to the nearest KKT point under inertia correction, so
125/// different starting points can reach different local optima on non-convex QPs.
126#[non_exhaustive]
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum StartStrategy {
129    /// Independent uniform sampling within box bounds (LCG).
130    RandomBox,
131    /// Latin Hypercube Sampling: partition each dimension into `n_starts`
132    /// strata and permute per column.  Better global coverage than pure random.
133    LatinHypercube,
134}
135
136/// Multi-start local search user-facing config.
137///
138/// Solves `n_starts` independent IPM problems from different starting points
139/// and returns the best objective.  Improves escape rate on non-convex QPs
140/// and supplies incumbents for spatial B&B.
141///
142/// **User-controlled (pub fields):**
143/// - `n_starts`: parallelism / hit probability
144/// - `seed`: reproducibility (`0` is internally clamped to 1 to avoid LCG lock)
145/// - `strategy`: sampling strategy
146///
147/// `n_starts == 1`: single cold solve (existing behaviour).
148/// `n_starts >= 2`: start #0 = cold, #1..n = random (warm_start_qp.x injected).
149/// All starts share the same deadline.
150#[derive(Debug, Clone)]
151pub struct MultiStartConfig {
152    /// Number of starting points.  1 disables multi-start.  Default = 1.
153    pub n_starts: usize,
154    /// Random seed.  Default = [`DEFAULT_MULTISTART_SEED`].
155    pub seed: u64,
156    /// Sampling strategy.  Default = `RandomBox`.
157    pub strategy: StartStrategy,
158}
159
160/// Default seed for [`MultiStartConfig`].  Fixed non-zero value for
161/// deterministic test environments.
162pub const DEFAULT_MULTISTART_SEED: u64 = 0x_00C0_FFEE_DEAD_BEEF;
163
164/// Branching strategy for spatial B&B.
165///
166/// `MaxViolation`: branch on the variable whose x* deviates most from the
167/// box midpoint, splitting at x*\[j\].
168#[non_exhaustive]
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum BranchingStrategy {
171    MaxViolation,
172}
173
174/// Defaults for [`GlobalOptimizationConfig`].
175///
176/// - `DEFAULT_GLOBAL_GAP_TOL = 1e-3`: Phase 3 interval-arithmetic bounds are
177///   loose; tightening to 1e-6 causes node explosion.  Phase 4 (α-BB) can tighten.
178/// - `DEFAULT_GLOBAL_MAX_DEPTH = 20`: tree depth cap (2^20 ≈ 1 M nodes).
179/// - `DEFAULT_GLOBAL_MAX_NODES = 10_000`: node budget (~1 IPM solve per node).
180pub const DEFAULT_GLOBAL_GAP_TOL: f64 = 1e-3;
181pub const DEFAULT_GLOBAL_MAX_DEPTH: usize = 20;
182pub const DEFAULT_GLOBAL_MAX_NODES: usize = 10_000;
183
184/// Spatial Branch-and-Bound config for global QP optimisation.
185///
186/// Set [`SolverOptions::global_optimization`] and call `solve_qp_global`
187/// explicitly.  `solve_qp_with` does **not** dispatch to this path (prevents
188/// accidental wall-time blow-up for existing users).
189///
190/// Rules:
191/// - `gap_tol > 0`: relative gap = |UB − LB| / max(1, |UB|)
192/// - `max_depth >= 1`, `max_nodes >= 1`
193#[derive(Debug, Clone)]
194pub struct GlobalOptimizationConfig {
195    pub gap_tol: f64,
196    pub max_depth: usize,
197    pub max_nodes: usize,
198    pub branching: BranchingStrategy,
199    pub use_alpha_bb: bool,
200    pub use_mccormick: bool,
201}
202
203impl Default for GlobalOptimizationConfig {
204    fn default() -> Self {
205        Self {
206            gap_tol: DEFAULT_GLOBAL_GAP_TOL,
207            max_depth: DEFAULT_GLOBAL_MAX_DEPTH,
208            max_nodes: DEFAULT_GLOBAL_MAX_NODES,
209            branching: BranchingStrategy::MaxViolation,
210            use_alpha_bb: true,
211            use_mccormick: false,
212        }
213    }
214}
215
216impl Default for MultiStartConfig {
217    fn default() -> Self {
218        Self {
219            n_starts: 1,
220            seed: DEFAULT_MULTISTART_SEED,
221            strategy: StartStrategy::RandomBox,
222        }
223    }
224}
225
226/// MILP/MIQP branching variable selection strategy.
227///
228/// `MostFractional`: branch on the integer-constrained variable whose
229/// relaxation value is closest to 0.5.  Ties broken by variable index.
230#[non_exhaustive]
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum MipBranching {
233    MostFractional,
234}
235
236/// Defaults for [`MipConfig`].
237///
238/// - `DEFAULT_MIP_GAP_TOL = 1e-6`: tighter than spatial B&B (1e-3) because LP/QP
239///   relaxations give exact lower bounds.
240/// - `DEFAULT_INTEGER_FEAS_TOL = 1e-6`: integrality threshold.
241/// - `DEFAULT_MIP_MAX_NODES = 1_000_000`: safety cap (deadline is primary cutoff).
242/// - `DEFAULT_MIP_MAX_DEPTH = 1_000`: depth cap.
243pub const DEFAULT_MIP_GAP_TOL: f64 = 1e-6;
244pub const DEFAULT_INTEGER_FEAS_TOL: f64 = 1e-6;
245pub const DEFAULT_MIP_MAX_NODES: usize = 1_000_000;
246pub const DEFAULT_MIP_MAX_DEPTH: usize = 1_000;
247/// Default root cutting-plane state. OFF for safe introduction: cuts only tighten
248/// the relaxation, so correctness is unchanged either way, but enabling them by
249/// default would change node counts / timings of every existing MILP solve. The
250/// effect is opted into explicitly (sentinels + bench show the ON benefit).
251pub const DEFAULT_MIP_CUTS: bool = false;
252/// `max_cut_rounds == 0` ⇒ use this many root cut rounds (auto). Kept small: most
253/// GMI gain is in the first few rounds, and deep rounds bloat the LP (slowing
254/// every downstream B&B node) for diminishing bound improvement.
255pub const DEFAULT_MAX_CUT_ROUNDS: usize = 5;
256
257/// MILP/MIQP branch-and-bound config.
258///
259/// Passed to `solve_milp` / `solve_miqp`.
260///
261/// Rules:
262/// - `gap_tol >= 0`: 0 means exact optimality (node explosion risk).
263/// - `integer_feas_tol > 0`
264/// - `max_nodes >= 1`, `max_depth >= 1`
265#[derive(Debug, Clone)]
266pub struct MipConfig {
267    pub gap_tol: f64,
268    pub integer_feas_tol: f64,
269    pub max_nodes: usize,
270    pub max_depth: usize,
271    pub branching: MipBranching,
272    /// Generate Gomory Mixed-Integer cuts at the root before branch-and-bound.
273    /// Cuts tighten the LP relaxation without removing any integer-feasible point,
274    /// so the optimum is unchanged; they reduce the search tree. Default OFF
275    /// (see [`DEFAULT_MIP_CUTS`]).
276    pub cuts: bool,
277    /// Maximum root cut-generation rounds. `0` ⇒ [`DEFAULT_MAX_CUT_ROUNDS`].
278    /// Each round re-solves the LP and adds GMI cuts from the fractional basic
279    /// integer variables; rounds stop early when no fractional source remains or
280    /// the LP bound stops improving.
281    pub max_cut_rounds: usize,
282}
283
284impl Default for MipConfig {
285    fn default() -> Self {
286        Self {
287            gap_tol: DEFAULT_MIP_GAP_TOL,
288            integer_feas_tol: DEFAULT_INTEGER_FEAS_TOL,
289            max_nodes: DEFAULT_MIP_MAX_NODES,
290            max_depth: DEFAULT_MIP_MAX_DEPTH,
291            branching: MipBranching::MostFractional,
292            cuts: DEFAULT_MIP_CUTS,
293            max_cut_rounds: DEFAULT_MAX_CUT_ROUNDS,
294        }
295    }
296}
297
298// ---- Tolerance --------------------------------------------------------
299
300/// IPM eps for [`Tolerance::High`].
301pub const TOLERANCE_HIGH_EPS: f64 = 1e-8;
302/// IPM eps for [`Tolerance::Medium`] (default).
303pub const TOLERANCE_MEDIUM_EPS: f64 = 1e-6;
304/// IPM eps for [`Tolerance::Fast`]: 100× looser than Medium for faster convergence.
305pub const TOLERANCE_FAST_EPS: f64 = 1e-4;
306
307/// Convergence accuracy level. Abstracts `ipm.eps`; the solver derives its
308/// internal threshold from this enum and ignores `ipm.eps`.
309///
310/// `High = 1e-8`, `Medium = 1e-6` (default, ≈ Gurobi), `Fast = 1e-4` (100× looser
311/// for reduced iter), `Custom(v) = v`. See [`TOLERANCE_HIGH_EPS`] etc.
312#[non_exhaustive]
313#[derive(Debug, Clone, Copy, PartialEq)]
314pub enum Tolerance {
315    /// High accuracy: research / verification workloads.
316    High,
317    /// Medium accuracy (default): general-purpose workloads.
318    Medium,
319    /// Fast: speed-priority, looser convergence (100× coarser than Medium).
320    Fast,
321    /// Custom: pass the eps value directly to each solver.
322    Custom(f64),
323}
324
325// ---- IpmOptions -------------------------------------------------------
326
327/// Default convergence tolerance for [`IpmOptions::eps`].
328pub const DEFAULT_IPM_EPS: f64 = 1e-6;
329/// Default proximity regularisation lower bound for [`IpmOptions::delta_min`].
330pub const DEFAULT_IPM_DELTA_MIN: f64 = 1e-8;
331/// Default initial proximity regularisation for [`IpmOptions::delta_p_init`]
332/// and [`IpmOptions::delta_d_init`].
333pub const DEFAULT_IPM_DELTA_INIT: f64 = 1e-6;
334/// Default Gondzio corrector count (Gondzio 1997, recommended range 2–5).
335pub const DEFAULT_IPM_MAX_CORRECTORS: usize = 3;
336
337/// IPM (interior-point method) solver options.
338///
339/// Set via [`SolverOptions::ipm`].  Call [`IpmOptions::validate`] (or
340/// [`SolverOptions::validate`]) before solving to catch invalid values early.
341#[derive(Debug, Clone)]
342pub struct IpmOptions {
343    /// Total IPM iterations across all attempts. Default: `usize::MAX` (timeout is the primary guard).
344    ///
345    /// Each attempt is internally capped at `MAX_ITER_PER_ATTEMPT` (currently 500);
346    /// this field is the cumulative budget across all retry attempts.
347    pub max_iter: usize,
348    /// Convergence tolerance.  Default: [`DEFAULT_IPM_EPS`].
349    pub eps: f64,
350    /// Proximity regularisation lower bound δ_min.  Default: [`DEFAULT_IPM_DELTA_MIN`].
351    pub delta_min: f64,
352    /// Initial primal proximity regularisation δ_p.  Default: [`DEFAULT_IPM_DELTA_INIT`].
353    pub delta_p_init: f64,
354    /// Initial dual proximity regularisation δ_d.  Default: [`DEFAULT_IPM_DELTA_INIT`].
355    pub delta_d_init: f64,
356    /// Maximum Gondzio correctors.  Default: [`DEFAULT_IPM_MAX_CORRECTORS`].
357    pub max_correctors: usize,
358    /// Use TwoFloat (double-double, ~106-bit) LDL for KKT systems where f64 conditioning
359    /// would exceed the requested accuracy.  Default: `false`.
360    pub dd_ldl: bool,
361    /// MINRES iterative-refinement rounds applied after each MINRES solve.
362    /// `None` uses 0 (disabled by default; auto-Schur makes this unnecessary in practice).
363    /// Must be `<= 10`.
364    #[doc(hidden)]
365    pub minres_ir: Option<usize>,
366    /// Memory budget for KKT LDL factorization in bytes.
367    /// `None` uses the 4 GiB default.  Factorizations predicted to exceed the budget
368    /// fall back to MINRES automatically.
369    #[doc(hidden)]
370    pub kkt_memory_budget_bytes: Option<usize>,
371}
372
373impl Default for IpmOptions {
374    fn default() -> Self {
375        Self {
376            max_iter: usize::MAX,
377            eps: DEFAULT_IPM_EPS,
378            delta_min: DEFAULT_IPM_DELTA_MIN,
379            delta_p_init: DEFAULT_IPM_DELTA_INIT,
380            delta_d_init: DEFAULT_IPM_DELTA_INIT,
381            max_correctors: DEFAULT_IPM_MAX_CORRECTORS,
382            dd_ldl: false,
383            minres_ir: None,
384            kkt_memory_budget_bytes: None,
385        }
386    }
387}
388
389impl IpmOptions {
390    /// Validate all numeric fields.
391    ///
392    /// Returns the first `Err` in field declaration order.
393    /// Invalid: non-finite or non-positive `eps` / `delta_*`, or `max_correctors == 0`.
394    pub fn validate(&self) -> Result<(), OptionsError> {
395        if !self.eps.is_finite() || self.eps <= 0.0 {
396            return Err(OptionsError {
397                field: "ipm.eps",
398                reason: "must be finite and > 0",
399            });
400        }
401        if !self.delta_min.is_finite() || self.delta_min <= 0.0 {
402            return Err(OptionsError {
403                field: "ipm.delta_min",
404                reason: "must be finite and > 0",
405            });
406        }
407        if !self.delta_p_init.is_finite() || self.delta_p_init <= 0.0 {
408            return Err(OptionsError {
409                field: "ipm.delta_p_init",
410                reason: "must be finite and > 0",
411            });
412        }
413        if !self.delta_d_init.is_finite() || self.delta_d_init <= 0.0 {
414            return Err(OptionsError {
415                field: "ipm.delta_d_init",
416                reason: "must be finite and > 0",
417            });
418        }
419        if self.max_correctors == 0 {
420            return Err(OptionsError {
421                field: "ipm.max_correctors",
422                reason: "must be >= 1",
423            });
424        }
425        if let Some(ir) = self.minres_ir {
426            if ir > 10 {
427                return Err(OptionsError {
428                    field: "ipm.minres_ir",
429                    reason: "must be <= 10",
430                });
431            }
432        }
433        Ok(())
434    }
435
436    /// Builder: set `eps`, validated immediately.
437    pub fn with_eps(mut self, eps: f64) -> Result<Self, OptionsError> {
438        if !eps.is_finite() || eps <= 0.0 {
439            return Err(OptionsError {
440                field: "ipm.eps",
441                reason: "must be finite and > 0",
442            });
443        }
444        self.eps = eps;
445        Ok(self)
446    }
447
448    /// Builder: set `max_correctors`, validated immediately.
449    pub fn with_max_correctors(mut self, n: usize) -> Result<Self, OptionsError> {
450        if n == 0 {
451            return Err(OptionsError {
452                field: "ipm.max_correctors",
453                reason: "must be >= 1",
454            });
455        }
456        self.max_correctors = n;
457        Ok(self)
458    }
459
460    /// Effective MINRES iterative-refinement rounds: resolves `None` to 0.
461    pub(crate) fn effective_minres_ir(&self) -> usize {
462        self.minres_ir.unwrap_or(0)
463    }
464
465    /// Effective KKT memory budget in bytes: resolves `None` to the built-in default (4 GiB).
466    pub(crate) fn effective_kkt_memory_budget_bytes(&self) -> usize {
467        use crate::linalg::kkt_solver::DEFAULT_MEMORY_BUDGET_BYTES;
468        self.kkt_memory_budget_bytes
469            .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES)
470    }
471
472    /// Max L-factor entries from memory budget (budget / bytes-per-entry).
473    pub(crate) fn effective_max_l_nnz(&self) -> usize {
474        use crate::linalg::kkt_solver::BYTES_PER_L_ENTRY;
475        self.effective_kkt_memory_budget_bytes() / BYTES_PER_L_ENTRY
476    }
477}
478
479// ---- SolverOptions ----------------------------------------------------
480
481/// Default clamp threshold for micro-values in solver output.
482pub const DEFAULT_CLAMP_TOL: f64 = 1e-14;
483
484/// Solver configuration.
485///
486/// Controls tolerances, iteration limits, refactorisation frequency, and
487/// algorithm selection.  `Default` uses values from `tolerances.rs`.
488///
489/// ## Validation
490///
491/// Call [`SolverOptions::validate`] (or use builder methods) before solving
492/// to catch invalid values (NaN, zero, negative tolerances, etc.) early.
493///
494/// ## Solver-specific parameters
495///
496/// Use the [`SolverOptions::ipm`] sub-struct for IPM-specific settings.
497#[derive(Debug, Clone)]
498pub struct SolverOptions {
499    // --- Common ---
500    /// Simplex primal feasibility / optimality threshold.  Default: `PIVOT_TOL`.
501    pub primal_tol: f64,
502    /// Max eta-file count (refactorisation threshold).  0 = auto (from problem size).
503    pub max_etas: usize,
504    /// Micro-value clamp threshold.  Default: [`DEFAULT_CLAMP_TOL`].
505    pub clamp_tol: f64,
506    /// Simplex algorithm selection.  Default: `Auto`.
507    pub simplex_method: SimplexMethod,
508    /// Dual feasibility threshold.  Default: `PIVOT_TOL`.
509    pub dual_tol: f64,
510    /// Dual simplex leaving strategy.  Default: `SteepestEdge`.
511    pub dual_pricing: DualPricing,
512    /// LP warm-start basis.  `None` = cold start.
513    pub warm_start: Option<WarmStartBasis>,
514    /// QP IP-PMM interior-point warm start for B&B node transfer.
515    pub warm_start_qp: Option<QpWarmStart>,
516    /// Extended LP warm start; takes priority over `warm_start`.
517    pub warm_start_lp: Option<LpWarmStart>,
518    /// Reconstruct `warm_start_basis` after postsolve.  Default: `false`.
519    ///
520    /// When presolve reduces the problem the reduced-LP basis indices are
521    /// invalid for the original LP.  `true` triggers basis reconstruction at
522    /// postsolve exit (LTSF crash + solution refinement).  Opt-in only.
523    ///
524    /// When presolve is skipped or the problem was not reduced, the simplex
525    /// basis is cloned directly regardless of this flag.
526    pub recover_warm_start_basis: bool,
527    /// Apply simplex crash basis on cold LP starts.  Ignored when
528    /// `warm_start` / `warm_start_lp` is set.
529    pub use_lp_crash_basis: bool,
530    /// Enable presolve.  Default: `true`.
531    pub presolve: bool,
532    /// Maximum fixpoint passes in QP presolve.  Default: `10`.
533    pub presolve_max_pass: usize,
534    /// Enable QP presolve phase 2.  Default: `true`.
535    pub presolve_phase2: bool,
536    /// Timeout in seconds.  `None` = unlimited.
537    pub timeout_secs: Option<f64>,
538    /// Shared cancellation flag (internal use).
539    pub(crate) cancel_flag: Option<Arc<AtomicBool>>,
540    /// Solve deadline computed from `timeout_secs` at solve entry (internal use).
541    pub(crate) deadline: Option<Instant>,
542
543    // --- Ruiz scaling ---
544    /// Apply Ruiz equilibration scaling before IPM.  Default: `true`.
545    pub use_ruiz_scaling: bool,
546
547    // --- Tolerance abstraction ---
548    /// Convergence accuracy level.  `None` = use `ipm.eps` directly.
549    ///
550    /// When `Some(_)`, each solver derives eps from this; `ipm.eps` is ignored.
551    pub tolerance: Option<Tolerance>,
552
553    // --- Solver-specific ---
554    /// IPM-specific options.
555    pub ipm: IpmOptions,
556
557    /// Multi-start local search config.  `None` (default) = disabled.
558    pub multistart: Option<MultiStartConfig>,
559
560    /// Spatial B&B global optimisation config.  `None` (default) = disabled.
561    /// Only consumed by explicit `solve_qp_global` calls.
562    pub global_optimization: Option<GlobalOptimizationConfig>,
563
564    /// Thread budget for all solver paths (LP / QP / multistart).
565    ///
566    /// Default = 1 (serial; no contention with external bench workers).
567    ///
568    /// - **QP** (`threads >= 2`): enables faer parallel sparse LDL on the KKT system.
569    /// - **LP simplex** (`threads >= 2`): no effect.
570    /// - **Multistart** (`threads >= 2`): `min(n_starts, threads)` parallel degree;
571    ///   inner solves forced to `threads = 1`.
572    pub threads: usize,
573
574    /// Reference optimal objective for early-exit.
575    ///
576    /// When `Some(ref_obj)`, returns `Optimal` as soon as
577    /// `|obj − ref_obj| / (1 + |ref_obj|) < OBJ_MATCH_REL_TOL`.
578    /// Used by bench harnesses.  `None` = no early-exit.
579    pub known_optimal_obj: Option<f64>,
580}
581
582/// Divisor for the `max_etas` heuristic: floor(m / MAX_ETAS_DIVISOR).
583const MAX_ETAS_DIVISOR: usize = 50;
584/// Minimum value for `default_max_etas`.
585const MAX_ETAS_FLOOR: usize = 20;
586
587/// Default maximum fixpoint passes for QP presolve.
588pub(crate) const DEFAULT_PRESOLVE_MAX_PASS: usize = 10;
589
590/// Auto-compute `max_etas` from problem size.
591///
592/// Small problems (m < 1000): `MAX_ETAS_FLOOR`; larger: m / `MAX_ETAS_DIVISOR`.
593pub fn default_max_etas(m: usize) -> usize {
594    (m / MAX_ETAS_DIVISOR).max(MAX_ETAS_FLOOR)
595}
596
597/// Phase I retry cap: guards against degenerate problems that loop with an
598/// identical basis in `revised_simplex_core`.
599pub(crate) const MAX_PHASE1_RETRIES: usize = 8;
600
601impl Default for SolverOptions {
602    fn default() -> Self {
603        Self {
604            primal_tol: PIVOT_TOL,
605            max_etas: 0,
606            clamp_tol: DEFAULT_CLAMP_TOL,
607            simplex_method: SimplexMethod::Auto,
608            dual_tol: PIVOT_TOL,
609            dual_pricing: DualPricing::default(),
610            warm_start: None,
611            warm_start_qp: None,
612            warm_start_lp: None,
613            recover_warm_start_basis: false,
614            use_lp_crash_basis: true,
615            presolve: true,
616            presolve_max_pass: DEFAULT_PRESOLVE_MAX_PASS,
617            presolve_phase2: true,
618            timeout_secs: None,
619            cancel_flag: None,
620            deadline: None,
621            use_ruiz_scaling: true,
622            tolerance: None,
623            ipm: IpmOptions::default(),
624            multistart: None,
625            global_optimization: None,
626            threads: 1,
627            known_optimal_obj: None,
628        }
629    }
630}
631
632impl SolverOptions {
633    /// Effective IPM eps: derived from `tolerance` if set, otherwise `ipm.eps`.
634    pub fn ipm_eps(&self) -> f64 {
635        match self.tolerance {
636            Some(Tolerance::High) => TOLERANCE_HIGH_EPS,
637            Some(Tolerance::Medium) => TOLERANCE_MEDIUM_EPS,
638            Some(Tolerance::Fast) => TOLERANCE_FAST_EPS,
639            Some(Tolerance::Custom(v)) => v,
640            None => self.ipm.eps,
641        }
642    }
643
644    /// Validate all option fields.
645    ///
646    /// Returns the first `Err` encountered, in field declaration order.
647    /// Called by public solver entry points (`solve_qp_with`, `solve_qp_global`,
648    /// `multistart::solve_qp_multistart`, `solve_milp`, `solve_miqp`, `simplex::solve_with`)
649    /// before starting work; invalid options cause the entry to return
650    /// [`crate::problem::SolveStatus::NumericalError`] rather than propagating
651    /// bad values into the solver core.
652    ///
653    /// Invalid conditions:
654    /// - `primal_tol` / `dual_tol`: non-finite or <= 0
655    /// - `clamp_tol`: non-finite or < 0 (0 is allowed)
656    /// - `threads`: 0
657    /// - `timeout_secs`: `Some(v)` where v is non-finite or < 0
658    /// - `tolerance`: `Custom(v)` where v is non-finite or <= 0
659    /// - Any field in [`IpmOptions`]
660    pub fn validate(&self) -> Result<(), OptionsError> {
661        if !self.primal_tol.is_finite() || self.primal_tol <= 0.0 {
662            return Err(OptionsError {
663                field: "primal_tol",
664                reason: "must be finite and > 0",
665            });
666        }
667        if !self.dual_tol.is_finite() || self.dual_tol <= 0.0 {
668            return Err(OptionsError {
669                field: "dual_tol",
670                reason: "must be finite and > 0",
671            });
672        }
673        if !self.clamp_tol.is_finite() || self.clamp_tol < 0.0 {
674            return Err(OptionsError {
675                field: "clamp_tol",
676                reason: "must be finite and >= 0",
677            });
678        }
679        if self.threads == 0 {
680            return Err(OptionsError {
681                field: "threads",
682                reason: "must be >= 1",
683            });
684        }
685        if let Some(t) = self.timeout_secs {
686            if !t.is_finite() || t < 0.0 {
687                return Err(OptionsError {
688                    field: "timeout_secs",
689                    reason: "must be finite and >= 0",
690                });
691            }
692        }
693        if let Some(Tolerance::Custom(v)) = self.tolerance {
694            if !v.is_finite() || v <= 0.0 {
695                return Err(OptionsError {
696                    field: "tolerance.Custom",
697                    reason: "must be finite and > 0",
698                });
699            }
700        }
701        self.ipm.validate()?;
702        Ok(())
703    }
704
705    /// Builder: set `timeout_secs`, validated immediately.
706    pub fn with_timeout(mut self, secs: f64) -> Result<Self, OptionsError> {
707        if !secs.is_finite() || secs < 0.0 {
708            return Err(OptionsError {
709                field: "timeout_secs",
710                reason: "must be finite and >= 0",
711            });
712        }
713        self.timeout_secs = Some(secs);
714        Ok(self)
715    }
716
717    /// Builder: set `threads`, validated immediately.
718    pub fn with_threads(mut self, n: usize) -> Result<Self, OptionsError> {
719        if n == 0 {
720            return Err(OptionsError {
721                field: "threads",
722                reason: "must be >= 1",
723            });
724        }
725        self.threads = n;
726        Ok(self)
727    }
728
729    /// Builder: set `tolerance`, validated immediately.
730    ///
731    /// `Tolerance::Custom(v)` requires v to be finite and > 0; other variants
732    /// are always accepted.
733    pub fn with_tolerance(mut self, tol: Tolerance) -> Result<Self, OptionsError> {
734        if let Tolerance::Custom(v) = tol {
735            if !v.is_finite() || v <= 0.0 {
736                return Err(OptionsError {
737                    field: "tolerance.Custom",
738                    reason: "must be finite and > 0",
739                });
740            }
741        }
742        self.tolerance = Some(tol);
743        Ok(self)
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // ---- DualPricing default sentinel ------------------------------------
752
753    /// Sentinel: `DualPricing::default()` must be `SteepestEdge`.
754    ///
755    /// Reverting `#[default]` to `MostInfeasible` silently degrades solver
756    /// performance.  This test fails immediately if the annotation is moved,
757    /// making the regression visible before any bench run.
758    ///
759    /// no-op proof: swapping `#[default]` back to `MostInfeasible` in the
760    /// enum declaration makes `DualPricing::default()` return `MostInfeasible`
761    /// → `assert_eq!` fails.
762    #[test]
763    fn dual_pricing_default_is_steepest_edge() {
764        assert_eq!(
765            DualPricing::default(),
766            DualPricing::SteepestEdge,
767            "DualPricing default must be SteepestEdge; \
768             moving #[default] to MostInfeasible will fail this sentinel"
769        );
770        let opts = SolverOptions::default();
771        assert_eq!(
772            opts.dual_pricing,
773            DualPricing::SteepestEdge,
774            "SolverOptions::default() must inherit DualPricing::SteepestEdge"
775        );
776    }
777
778    // ---- Tolerance translation -------------------------------------------
779
780    #[test]
781    fn test_tolerance_translation() {
782        // Table-driven: (tolerance setting, expected ipm_eps)
783        let cases: &[(Option<Tolerance>, f64)] = &[
784            (Some(Tolerance::High), TOLERANCE_HIGH_EPS),
785            (Some(Tolerance::Medium), TOLERANCE_MEDIUM_EPS),
786            (Some(Tolerance::Fast), TOLERANCE_FAST_EPS),
787            (Some(Tolerance::Custom(1e-5)), 1e-5),
788            (None, DEFAULT_IPM_EPS), // uses ipm.eps default
789        ];
790        for (tol, expected) in cases {
791            let opts = SolverOptions {
792                tolerance: *tol,
793                ..Default::default()
794            };
795            assert_eq!(opts.ipm_eps(), *expected, "tolerance = {:?}", tol);
796        }
797    }
798
799    #[test]
800    #[allow(clippy::assertions_on_constants)]
801    fn test_tolerance_fast_is_looser_than_medium() {
802        // Fast must be coarser (larger eps) than Medium; otherwise the name is misleading.
803        const { assert!(TOLERANCE_FAST_EPS > TOLERANCE_MEDIUM_EPS) }
804        const { assert!(TOLERANCE_MEDIUM_EPS > TOLERANCE_HIGH_EPS) }
805    }
806
807    // ---- IpmOptions::validate -------------------------------------------
808
809    #[test]
810    fn test_ipm_validate_defaults_ok() {
811        assert!(IpmOptions::default().validate().is_ok());
812    }
813
814    #[test]
815    fn test_ipm_validate_eps() {
816        for bad in [0.0_f64, -1e-6, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
817            let o = IpmOptions {
818                eps: bad,
819                ..Default::default()
820            };
821            assert!(o.validate().is_err(), "eps={bad} should be invalid");
822        }
823        // boundary: smallest positive finite value is valid
824        let o = IpmOptions {
825            eps: f64::MIN_POSITIVE,
826            ..Default::default()
827        };
828        assert!(o.validate().is_ok());
829    }
830
831    #[test]
832    fn test_ipm_validate_delta_min() {
833        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
834            let o = IpmOptions {
835                delta_min: bad,
836                ..Default::default()
837            };
838            assert!(o.validate().is_err(), "delta_min={bad} should be invalid");
839        }
840    }
841
842    #[test]
843    fn test_ipm_validate_delta_p_init() {
844        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
845            let o = IpmOptions {
846                delta_p_init: bad,
847                ..Default::default()
848            };
849            assert!(
850                o.validate().is_err(),
851                "delta_p_init={bad} should be invalid"
852            );
853        }
854    }
855
856    #[test]
857    fn test_ipm_validate_delta_d_init() {
858        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
859            let o = IpmOptions {
860                delta_d_init: bad,
861                ..Default::default()
862            };
863            assert!(
864                o.validate().is_err(),
865                "delta_d_init={bad} should be invalid"
866            );
867        }
868    }
869
870    #[test]
871    fn test_ipm_validate_max_correctors() {
872        let o = IpmOptions {
873            max_correctors: 0,
874            ..Default::default()
875        };
876        assert!(o.validate().is_err(), "max_correctors=0 should be invalid");
877        let o = IpmOptions {
878            max_correctors: 1,
879            ..Default::default()
880        };
881        assert!(o.validate().is_ok());
882    }
883
884    // ---- IpmOptions builders --------------------------------------------
885
886    #[test]
887    fn test_ipm_builder_with_eps() {
888        assert!(IpmOptions::default().with_eps(1e-4).is_ok());
889        assert!(IpmOptions::default().with_eps(f64::MIN_POSITIVE).is_ok());
890        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
891            assert!(
892                IpmOptions::default().with_eps(bad).is_err(),
893                "with_eps({bad}) should err"
894            );
895        }
896    }
897
898    #[test]
899    fn test_ipm_builder_with_max_correctors() {
900        assert!(IpmOptions::default().with_max_correctors(1).is_ok());
901        assert!(IpmOptions::default().with_max_correctors(10).is_ok());
902        assert!(IpmOptions::default().with_max_correctors(0).is_err());
903    }
904
905    // ---- SolverOptions::validate ----------------------------------------
906
907    #[test]
908    fn test_solver_validate_defaults_ok() {
909        assert!(SolverOptions::default().validate().is_ok());
910    }
911
912    #[test]
913    fn test_solver_validate_primal_tol() {
914        for bad in [0.0_f64, -1e-8, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
915            let o = SolverOptions {
916                primal_tol: bad,
917                ..Default::default()
918            };
919            assert!(o.validate().is_err(), "primal_tol={bad}");
920        }
921        let o = SolverOptions {
922            primal_tol: f64::MIN_POSITIVE,
923            ..Default::default()
924        };
925        assert!(o.validate().is_ok());
926    }
927
928    #[test]
929    fn test_solver_validate_dual_tol() {
930        for bad in [0.0_f64, -1e-8, f64::NAN, f64::INFINITY] {
931            let o = SolverOptions {
932                dual_tol: bad,
933                ..Default::default()
934            };
935            assert!(o.validate().is_err(), "dual_tol={bad}");
936        }
937    }
938
939    #[test]
940    fn test_solver_validate_clamp_tol() {
941        // 0.0 is valid (no clamping)
942        let o = SolverOptions {
943            clamp_tol: 0.0,
944            ..Default::default()
945        };
946        assert!(o.validate().is_ok(), "clamp_tol=0 should be ok");
947        for bad in [-1.0_f64, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
948            let o = SolverOptions {
949                clamp_tol: bad,
950                ..Default::default()
951            };
952            assert!(o.validate().is_err(), "clamp_tol={bad}");
953        }
954    }
955
956    #[test]
957    fn test_solver_validate_threads() {
958        let o = SolverOptions {
959            threads: 0,
960            ..Default::default()
961        };
962        assert!(o.validate().is_err(), "threads=0");
963        for ok in [1_usize, 2, 8, usize::MAX] {
964            let o = SolverOptions {
965                threads: ok,
966                ..Default::default()
967            };
968            assert!(o.validate().is_ok(), "threads={ok}");
969        }
970    }
971
972    #[test]
973    fn test_solver_validate_timeout_secs() {
974        // None is always valid
975        assert!(SolverOptions {
976            timeout_secs: None,
977            ..Default::default()
978        }
979        .validate()
980        .is_ok());
981        // non-negative finite: valid (0.0 = immediately-expired deadline)
982        for ok in [0.0_f64, 0.001, 1.0, 1000.0] {
983            let o = SolverOptions {
984                timeout_secs: Some(ok),
985                ..Default::default()
986            };
987            assert!(
988                o.validate().is_ok(),
989                "timeout_secs=Some({ok}) must be valid"
990            );
991        }
992        // invalid: negative, NaN, or infinite
993        for bad in [-1.0_f64, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
994            let o = SolverOptions {
995                timeout_secs: Some(bad),
996                ..Default::default()
997            };
998            assert!(o.validate().is_err(), "timeout_secs=Some({bad})");
999        }
1000    }
1001
1002    #[test]
1003    fn test_solver_validate_tolerance_custom() {
1004        // Non-Custom variants are always valid
1005        for tol in [Tolerance::High, Tolerance::Medium, Tolerance::Fast] {
1006            let o = SolverOptions {
1007                tolerance: Some(tol),
1008                ..Default::default()
1009            };
1010            assert!(o.validate().is_ok(), "tolerance={tol:?}");
1011        }
1012        // Custom: valid
1013        let o = SolverOptions {
1014            tolerance: Some(Tolerance::Custom(1e-5)),
1015            ..Default::default()
1016        };
1017        assert!(o.validate().is_ok());
1018        // Custom: invalid
1019        for bad in [0.0_f64, -1e-4, f64::NAN, f64::INFINITY] {
1020            let o = SolverOptions {
1021                tolerance: Some(Tolerance::Custom(bad)),
1022                ..Default::default()
1023            };
1024            assert!(o.validate().is_err(), "Tolerance::Custom({bad})");
1025        }
1026    }
1027
1028    #[test]
1029    fn test_solver_validate_propagates_ipm() {
1030        // SolverOptions::validate must propagate IpmOptions::validate errors.
1031        let o = SolverOptions {
1032            ipm: IpmOptions {
1033                eps: 0.0,
1034                ..Default::default()
1035            },
1036            ..Default::default()
1037        };
1038        assert!(o.validate().is_err(), "ipm.eps=0 must propagate");
1039
1040        let o = SolverOptions {
1041            ipm: IpmOptions {
1042                max_correctors: 0,
1043                ..Default::default()
1044            },
1045            ..Default::default()
1046        };
1047        assert!(o.validate().is_err(), "ipm.max_correctors=0 must propagate");
1048    }
1049
1050    // ---- SolverOptions builders -----------------------------------------
1051
1052    #[test]
1053    fn test_solver_builder_with_timeout() {
1054        assert!(SolverOptions::default().with_timeout(10.0).is_ok());
1055        assert!(SolverOptions::default().with_timeout(0.001).is_ok());
1056        assert!(
1057            SolverOptions::default().with_timeout(0.0).is_ok(),
1058            "0.0 = immediately-expired deadline"
1059        );
1060        for bad in [-1.0_f64, f64::NAN, f64::INFINITY] {
1061            assert!(
1062                SolverOptions::default().with_timeout(bad).is_err(),
1063                "with_timeout({bad})"
1064            );
1065        }
1066        // Result carries the set value
1067        let o = SolverOptions::default().with_timeout(5.0).unwrap();
1068        assert_eq!(o.timeout_secs, Some(5.0));
1069    }
1070
1071    #[test]
1072    fn test_solver_builder_with_threads() {
1073        assert!(SolverOptions::default().with_threads(1).is_ok());
1074        assert!(SolverOptions::default().with_threads(8).is_ok());
1075        assert!(SolverOptions::default().with_threads(0).is_err());
1076        let o = SolverOptions::default().with_threads(4).unwrap();
1077        assert_eq!(o.threads, 4);
1078    }
1079
1080    #[test]
1081    fn test_solver_builder_with_tolerance() {
1082        assert!(SolverOptions::default()
1083            .with_tolerance(Tolerance::High)
1084            .is_ok());
1085        assert!(SolverOptions::default()
1086            .with_tolerance(Tolerance::Medium)
1087            .is_ok());
1088        assert!(SolverOptions::default()
1089            .with_tolerance(Tolerance::Fast)
1090            .is_ok());
1091        assert!(SolverOptions::default()
1092            .with_tolerance(Tolerance::Custom(1e-5))
1093            .is_ok());
1094        for bad in [0.0_f64, -1e-4, f64::NAN, f64::INFINITY] {
1095            assert!(
1096                SolverOptions::default()
1097                    .with_tolerance(Tolerance::Custom(bad))
1098                    .is_err(),
1099                "with_tolerance(Custom({bad}))"
1100            );
1101        }
1102        let o = SolverOptions::default()
1103            .with_tolerance(Tolerance::Fast)
1104            .unwrap();
1105        assert_eq!(o.tolerance, Some(Tolerance::Fast));
1106    }
1107
1108    // ---- OptionsError display -------------------------------------------
1109
1110    #[test]
1111    fn test_options_error_display() {
1112        let e = OptionsError {
1113            field: "ipm.eps",
1114            reason: "must be finite and > 0",
1115        };
1116        let s = e.to_string();
1117        assert!(s.contains("ipm.eps"), "display: {s}");
1118        assert!(s.contains("finite"), "display: {s}");
1119    }
1120
1121    // ---- IpmOptions: new fields defaults and resolution ----------------
1122
1123    #[test]
1124    fn test_ipm_new_fields_default() {
1125        let o = IpmOptions::default();
1126        assert!(!o.dd_ldl, "dd_ldl default false");
1127        assert!(o.minres_ir.is_none(), "minres_ir default None");
1128        assert!(
1129            o.kkt_memory_budget_bytes.is_none(),
1130            "kkt_memory_budget_bytes default None"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_ipm_effective_minres_ir_default_and_override() {
1136        let o = IpmOptions::default();
1137        assert_eq!(o.effective_minres_ir(), 0, "default IR = 0");
1138        let o2 = IpmOptions {
1139            minres_ir: Some(3),
1140            ..Default::default()
1141        };
1142        assert_eq!(o2.effective_minres_ir(), 3);
1143    }
1144
1145    #[test]
1146    fn test_ipm_validate_minres_ir() {
1147        // Default (None) and valid values
1148        assert!(IpmOptions::default().validate().is_ok());
1149        for ok in [0_usize, 1, 5, 10] {
1150            let o = IpmOptions {
1151                minres_ir: Some(ok),
1152                ..Default::default()
1153            };
1154            assert!(o.validate().is_ok(), "minres_ir={ok} should be valid");
1155        }
1156        // Out of range: > 10
1157        for bad in [11_usize, 100, usize::MAX] {
1158            let o = IpmOptions {
1159                minres_ir: Some(bad),
1160                ..Default::default()
1161            };
1162            assert!(o.validate().is_err(), "minres_ir={bad} should be invalid");
1163        }
1164        // Default const upper-bound is guaranteed by const_assert next to definition.
1165    }
1166
1167    #[test]
1168    fn test_ipm_effective_max_l_nnz_default_and_override() {
1169        use crate::linalg::kkt_solver::{BYTES_PER_L_ENTRY, DEFAULT_MEMORY_BUDGET_BYTES};
1170        let o = IpmOptions::default();
1171        assert_eq!(
1172            o.effective_kkt_memory_budget_bytes(),
1173            DEFAULT_MEMORY_BUDGET_BYTES
1174        );
1175        assert_eq!(
1176            o.effective_max_l_nnz(),
1177            DEFAULT_MEMORY_BUDGET_BYTES / BYTES_PER_L_ENTRY
1178        );
1179        let o2 = IpmOptions {
1180            kkt_memory_budget_bytes: Some(1600),
1181            ..Default::default()
1182        };
1183        assert_eq!(o2.effective_max_l_nnz(), 1600 / BYTES_PER_L_ENTRY);
1184    }
1185
1186    // ---- SolverOptions: presolve fields --------------------------------
1187
1188    #[test]
1189    fn test_solver_presolve_fields_default() {
1190        let o = SolverOptions::default();
1191        assert_eq!(
1192            o.presolve_max_pass, DEFAULT_PRESOLVE_MAX_PASS,
1193            "default max pass"
1194        );
1195        assert!(o.presolve_phase2, "default phase2 = true");
1196    }
1197
1198    #[test]
1199    fn test_presolve_max_pass_controls_iteration_count() {
1200        use crate::problem::SolveStatus;
1201        use crate::qp::{solve_qp_with, QpProblem};
1202        use crate::sparse::CscMatrix;
1203
1204        // Minimal feasible QP: 1 variable, no constraints, x* = 0.
1205        let q = CscMatrix::from_triplets(&[0], &[0], &[2.0], 1, 1).unwrap();
1206        let a = CscMatrix::new(0, 1);
1207        let prob =
1208            QpProblem::new(q, vec![0.0], a, vec![], vec![(0.0_f64, 1.0_f64)], vec![]).unwrap();
1209
1210        // Both 0 and 10 passes must find the optimum.
1211        let opts0 = SolverOptions {
1212            presolve_max_pass: 0,
1213            ..Default::default()
1214        };
1215        let opts10 = SolverOptions {
1216            presolve_max_pass: 10,
1217            ..Default::default()
1218        };
1219        let r0 = solve_qp_with(&prob, &opts0);
1220        let r10 = solve_qp_with(&prob, &opts10);
1221        assert_eq!(
1222            r0.status,
1223            SolveStatus::Optimal,
1224            "presolve_max_pass=0 should still solve trivial QP"
1225        );
1226        assert_eq!(
1227            r10.status,
1228            SolveStatus::Optimal,
1229            "presolve_max_pass=10 should solve trivial QP"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_presolve_phase2_false_skips_phase2() {
1235        // When presolve_phase2=false, attempt.rs takes the phase1-only branch.
1236        // Verify through options field round-trip.
1237        let o = SolverOptions {
1238            presolve_phase2: false,
1239            ..Default::default()
1240        };
1241        assert!(!o.presolve_phase2);
1242        let o2 = SolverOptions {
1243            presolve_phase2: true,
1244            ..Default::default()
1245        };
1246        assert!(o2.presolve_phase2);
1247    }
1248}