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::{
13    atomic::AtomicBool,
14    Arc,
15};
16use std::time::Instant;
17
18// ---- Error type -------------------------------------------------------
19
20/// Error returned when option values fail validation.
21///
22/// Produced by [`IpmOptions::validate`] and [`SolverOptions::validate`], and
23/// by builder methods (`with_*`) that validate on assignment.
24#[derive(Debug, Clone, PartialEq)]
25pub struct OptionsError {
26    /// Name of the offending field (e.g. `"ipm.eps"`).
27    pub field: &'static str,
28    /// Human-readable rejection reason.
29    pub reason: &'static str,
30}
31
32impl std::fmt::Display for OptionsError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(f, "invalid option `{}`: {}", self.field, self.reason)
35    }
36}
37
38impl std::error::Error for OptionsError {}
39
40// ---- Enum / simple struct types ---------------------------------------
41
42/// Dual simplex leaving (depart) strategy.
43///
44/// `MostInfeasible`: select the most negative x_B[i] (Dantzig rule).
45/// Stable but inflates iteration count on large problems.
46///
47/// `SteepestEdge`: Forrest-Goldfarb 1992 Dual Steepest Edge.
48/// Maintains weight γ_i = ||(B^{-1})_{i,:}||² and maximises
49/// score = x_B[i]² / γ_i.  Typical 3-10× speed-up (HiGHS/CPLEX) at the cost
50/// of one extra FTRAN per iteration.
51///
52/// Default: `MostInfeasible` (easy A/B comparison; preserves existing behaviour).
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum DualPricing {
56    #[default]
57    MostInfeasible,
58    SteepestEdge,
59}
60
61/// Simplex algorithm selection.
62#[non_exhaustive]
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum SimplexMethod {
65    /// Auto-select based on warm-start availability.
66    #[default]
67    Auto,
68    /// Force Primal Simplex.
69    Primal,
70    /// Force Dual Simplex.
71    Dual,
72    /// Production-quality Dual Simplex (`dual_advanced` module).
73    DualAdvanced,
74}
75
76/// Basis information for warm-starting simplex.
77///
78/// Carries basis indices and primal values from a previous solve. Used as the
79/// initial basis for Dual Simplex in SQP integration.
80#[derive(Debug, Clone)]
81pub struct WarmStartBasis {
82    /// Basis variable indices (standard-form column numbers, length = m).
83    pub basis: Vec<usize>,
84    /// Basis variable values x_B (length = m). Stale values are acceptable;
85    /// they are recomputed from the new RHS on warm-start entry.
86    pub x_b: Vec<f64>,
87}
88
89/// QP IP-PMM interior-point warm-start data.
90///
91/// Passes the optimal (x, y, μ) from a parent B&B node as the starting point
92/// on the central path for the child node.  LP warm-start uses basis indices
93/// ([`WarmStartBasis`]); QP warm-start uses a central-path point.
94///
95/// Convention:
96/// - `x`: length = n (primal)
97/// - `y`: length = m (dual, user sign convention; Ge constraints inverted internally)
98/// - `mu`: barrier parameter ≈ sᵀy / m_ineq of the parent final iterate
99///
100/// Interior corrections (μ floor / x bound margin / y positivity) are applied
101/// on entry so boundary or zero values are safe to pass.
102#[derive(Debug, Clone)]
103pub struct QpWarmStart {
104    pub x: Vec<f64>,
105    pub y: Vec<f64>,
106    pub mu: f64,
107}
108
109/// Extended LP warm-start.
110///
111/// Superset of [`WarmStartBasis`]: accepts (x, y, basis) from an external
112/// solver and lands simplex at that point.  Takes priority over `warm_start`.
113///
114/// Convention:
115/// - `basis`: length = m_ext (standard-form rows), each value < n_total.
116///   Size mismatch: logged and dropped (not silently ignored).
117/// - `x_orig`: length = problem.num_vars (original variable space)
118/// - `y_orig`: length = problem.num_constraints (original constraint space, user sign)
119#[derive(Debug, Clone)]
120pub struct LpWarmStart {
121    pub basis: Vec<usize>,
122    pub x_orig: Option<Vec<f64>>,
123    pub y_orig: Option<Vec<f64>>,
124}
125
126/// Multi-start sampling strategy.
127///
128/// IPM converges to the nearest KKT point under inertia correction, so
129/// different starting points can reach different local optima on non-convex QPs.
130#[non_exhaustive]
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum StartStrategy {
133    /// Independent uniform sampling within box bounds (LCG).
134    RandomBox,
135    /// Latin Hypercube Sampling: partition each dimension into `n_starts`
136    /// strata and permute per column.  Better global coverage than pure random.
137    LatinHypercube,
138}
139
140/// Multi-start local search user-facing config.
141///
142/// Solves `n_starts` independent IPM problems from different starting points
143/// and returns the best objective.  Improves escape rate on non-convex QPs
144/// and supplies incumbents for spatial B&B.
145///
146/// **User-controlled (pub fields):**
147/// - `n_starts`: parallelism / hit probability
148/// - `seed`: reproducibility (`0` is internally clamped to 1 to avoid LCG lock)
149/// - `strategy`: sampling strategy
150///
151/// `n_starts == 1`: single cold solve (existing behaviour).
152/// `n_starts >= 2`: start #0 = cold, #1..n = random (warm_start_qp.x injected).
153/// All starts share the same deadline.
154#[derive(Debug, Clone)]
155pub struct MultiStartConfig {
156    /// Number of starting points.  1 disables multi-start.  Default = 1.
157    pub n_starts: usize,
158    /// Random seed.  Default = [`DEFAULT_MULTISTART_SEED`].
159    pub seed: u64,
160    /// Sampling strategy.  Default = `RandomBox`.
161    pub strategy: StartStrategy,
162}
163
164/// Default seed for [`MultiStartConfig`].  Fixed non-zero value for
165/// deterministic test environments.
166pub const DEFAULT_MULTISTART_SEED: u64 = 0x_00C0_FFEE_DEAD_BEEF;
167
168/// Branching strategy for spatial B&B.
169///
170/// `MaxViolation`: branch on the variable whose x* deviates most from the
171/// box midpoint, splitting at x*[j].
172#[non_exhaustive]
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum BranchingStrategy {
175    MaxViolation,
176}
177
178/// Defaults for [`GlobalOptimizationConfig`].
179///
180/// - `DEFAULT_GLOBAL_GAP_TOL = 1e-3`: Phase 3 interval-arithmetic bounds are
181///   loose; tightening to 1e-6 causes node explosion.  Phase 4 (α-BB) can tighten.
182/// - `DEFAULT_GLOBAL_MAX_DEPTH = 20`: tree depth cap (2^20 ≈ 1 M nodes).
183/// - `DEFAULT_GLOBAL_MAX_NODES = 10_000`: node budget (~1 IPM solve per node).
184pub const DEFAULT_GLOBAL_GAP_TOL: f64 = 1e-3;
185pub const DEFAULT_GLOBAL_MAX_DEPTH: usize = 20;
186pub const DEFAULT_GLOBAL_MAX_NODES: usize = 10_000;
187
188/// Spatial Branch-and-Bound config for global QP optimisation.
189///
190/// Set [`SolverOptions::global_optimization`] and call `solve_qp_global`
191/// explicitly.  `solve_qp_with` does **not** dispatch to this path (prevents
192/// accidental wall-time blow-up for existing users).
193///
194/// Rules:
195/// - `gap_tol > 0`: relative gap = |UB − LB| / max(1, |UB|)
196/// - `max_depth >= 1`, `max_nodes >= 1`
197#[derive(Debug, Clone)]
198pub struct GlobalOptimizationConfig {
199    pub gap_tol: f64,
200    pub max_depth: usize,
201    pub max_nodes: usize,
202    pub branching: BranchingStrategy,
203    pub use_alpha_bb: bool,
204    pub use_mccormick: bool,
205}
206
207impl Default for GlobalOptimizationConfig {
208    fn default() -> Self {
209        Self {
210            gap_tol: DEFAULT_GLOBAL_GAP_TOL,
211            max_depth: DEFAULT_GLOBAL_MAX_DEPTH,
212            max_nodes: DEFAULT_GLOBAL_MAX_NODES,
213            branching: BranchingStrategy::MaxViolation,
214            use_alpha_bb: true,
215            use_mccormick: false,
216        }
217    }
218}
219
220impl Default for MultiStartConfig {
221    fn default() -> Self {
222        Self {
223            n_starts: 1,
224            seed: DEFAULT_MULTISTART_SEED,
225            strategy: StartStrategy::RandomBox,
226        }
227    }
228}
229
230/// MILP/MIQP branching variable selection strategy.
231///
232/// `MostFractional`: branch on the integer-constrained variable whose
233/// relaxation value is closest to 0.5.  Ties broken by variable index.
234#[non_exhaustive]
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum MipBranching {
237    MostFractional,
238}
239
240/// Defaults for [`MipConfig`].
241///
242/// - `DEFAULT_MIP_GAP_TOL = 1e-6`: tighter than spatial B&B (1e-3) because LP/QP
243///   relaxations give exact lower bounds.
244/// - `DEFAULT_INTEGER_FEAS_TOL = 1e-6`: integrality threshold.
245/// - `DEFAULT_MIP_MAX_NODES = 1_000_000`: safety cap (deadline is primary cutoff).
246/// - `DEFAULT_MIP_MAX_DEPTH = 1_000`: depth cap.
247pub const DEFAULT_MIP_GAP_TOL: f64 = 1e-6;
248pub const DEFAULT_INTEGER_FEAS_TOL: f64 = 1e-6;
249pub const DEFAULT_MIP_MAX_NODES: usize = 1_000_000;
250pub const DEFAULT_MIP_MAX_DEPTH: usize = 1_000;
251
252/// MILP/MIQP branch-and-bound config.
253///
254/// Passed to `solve_milp` / `solve_miqp`.
255///
256/// Rules:
257/// - `gap_tol >= 0`: 0 means exact optimality (node explosion risk).
258/// - `integer_feas_tol > 0`
259/// - `max_nodes >= 1`, `max_depth >= 1`
260#[derive(Debug, Clone)]
261pub struct MipConfig {
262    pub gap_tol: f64,
263    pub integer_feas_tol: f64,
264    pub max_nodes: usize,
265    pub max_depth: usize,
266    pub branching: MipBranching,
267}
268
269impl Default for MipConfig {
270    fn default() -> Self {
271        Self {
272            gap_tol: DEFAULT_MIP_GAP_TOL,
273            integer_feas_tol: DEFAULT_INTEGER_FEAS_TOL,
274            max_nodes: DEFAULT_MIP_MAX_NODES,
275            max_depth: DEFAULT_MIP_MAX_DEPTH,
276            branching: MipBranching::MostFractional,
277        }
278    }
279}
280
281// ---- Tolerance --------------------------------------------------------
282
283/// IPM eps for [`Tolerance::High`].
284pub const TOLERANCE_HIGH_EPS: f64 = 1e-8;
285/// IPM eps for [`Tolerance::Medium`] (default).
286pub const TOLERANCE_MEDIUM_EPS: f64 = 1e-6;
287/// IPM eps for [`Tolerance::Fast`]: 100× looser than Medium for faster convergence.
288pub const TOLERANCE_FAST_EPS: f64 = 1e-4;
289
290/// Convergence accuracy level.
291///
292/// Abstracts the raw `ipm.eps` field.  When set on [`SolverOptions`], the
293/// solver derives its internal convergence threshold from this enum;
294/// `ipm.eps` is ignored.
295///
296/// ## Translation table
297///
298/// | Tolerance | IPM eps                              |
299/// |-----------|--------------------------------------|
300/// | High      | [`TOLERANCE_HIGH_EPS`] = 1e-8        |
301/// | Medium    | [`TOLERANCE_MEDIUM_EPS`] = 1e-6      |
302/// | Fast      | [`TOLERANCE_FAST_EPS`] = 1e-4        |
303/// | Custom(v) | v                                    |
304///
305/// `Medium` is the default (comparable to Gurobi `eps = 1e-6`).
306/// `Fast` accepts solutions 100× less precise than Medium for reduced
307/// iteration counts — appropriate when a coarse objective estimate suffices.
308#[non_exhaustive]
309#[derive(Debug, Clone, Copy, PartialEq)]
310pub enum Tolerance {
311    /// High accuracy: research / verification workloads.
312    High,
313    /// Medium accuracy (default): general-purpose workloads.
314    Medium,
315    /// Fast: speed-priority, looser convergence (100× coarser than Medium).
316    Fast,
317    /// Custom: pass the eps value directly to each solver.
318    Custom(f64),
319}
320
321// ---- IpmOptions -------------------------------------------------------
322
323/// Default convergence tolerance for [`IpmOptions::eps`].
324pub const DEFAULT_IPM_EPS: f64 = 1e-6;
325/// Default proximity regularisation lower bound for [`IpmOptions::delta_min`].
326pub const DEFAULT_IPM_DELTA_MIN: f64 = 1e-8;
327/// Default initial proximity regularisation for [`IpmOptions::delta_p_init`]
328/// and [`IpmOptions::delta_d_init`].
329pub const DEFAULT_IPM_DELTA_INIT: f64 = 1e-6;
330/// Default Gondzio corrector count (Gondzio 1997, recommended range 2–5).
331pub const DEFAULT_IPM_MAX_CORRECTORS: usize = 3;
332
333/// IPM (interior-point method) solver options.
334///
335/// Set via [`SolverOptions::ipm`].  Call [`IpmOptions::validate`] (or
336/// [`SolverOptions::validate`]) before solving to catch invalid values early.
337#[derive(Debug, Clone)]
338pub struct IpmOptions {
339    /// Maximum iterations.  Default: `usize::MAX` (timeout is the primary guard).
340    pub max_iter: usize,
341    /// Convergence tolerance.  Default: [`DEFAULT_IPM_EPS`].
342    pub eps: f64,
343    /// Proximity regularisation lower bound δ_min.  Default: [`DEFAULT_IPM_DELTA_MIN`].
344    pub delta_min: f64,
345    /// Initial primal proximity regularisation δ_p.  Default: [`DEFAULT_IPM_DELTA_INIT`].
346    pub delta_p_init: f64,
347    /// Initial dual proximity regularisation δ_d.  Default: [`DEFAULT_IPM_DELTA_INIT`].
348    pub delta_d_init: f64,
349    /// Maximum Gondzio correctors.  Default: [`DEFAULT_IPM_MAX_CORRECTORS`].
350    pub max_correctors: usize,
351}
352
353impl Default for IpmOptions {
354    fn default() -> Self {
355        Self {
356            max_iter: usize::MAX,
357            eps: DEFAULT_IPM_EPS,
358            delta_min: DEFAULT_IPM_DELTA_MIN,
359            delta_p_init: DEFAULT_IPM_DELTA_INIT,
360            delta_d_init: DEFAULT_IPM_DELTA_INIT,
361            max_correctors: DEFAULT_IPM_MAX_CORRECTORS,
362        }
363    }
364}
365
366impl IpmOptions {
367    /// Validate all numeric fields.
368    ///
369    /// Returns the first `Err` in field declaration order.
370    /// Invalid: non-finite or non-positive `eps` / `delta_*`, or `max_correctors == 0`.
371    pub fn validate(&self) -> Result<(), OptionsError> {
372        if !self.eps.is_finite() || self.eps <= 0.0 {
373            return Err(OptionsError { field: "ipm.eps", reason: "must be finite and > 0" });
374        }
375        if !self.delta_min.is_finite() || self.delta_min <= 0.0 {
376            return Err(OptionsError { field: "ipm.delta_min", reason: "must be finite and > 0" });
377        }
378        if !self.delta_p_init.is_finite() || self.delta_p_init <= 0.0 {
379            return Err(OptionsError { field: "ipm.delta_p_init", reason: "must be finite and > 0" });
380        }
381        if !self.delta_d_init.is_finite() || self.delta_d_init <= 0.0 {
382            return Err(OptionsError { field: "ipm.delta_d_init", reason: "must be finite and > 0" });
383        }
384        if self.max_correctors == 0 {
385            return Err(OptionsError { field: "ipm.max_correctors", reason: "must be >= 1" });
386        }
387        Ok(())
388    }
389
390    /// Builder: set `eps`, validated immediately.
391    pub fn with_eps(mut self, eps: f64) -> Result<Self, OptionsError> {
392        if !eps.is_finite() || eps <= 0.0 {
393            return Err(OptionsError { field: "ipm.eps", reason: "must be finite and > 0" });
394        }
395        self.eps = eps;
396        Ok(self)
397    }
398
399    /// Builder: set `max_correctors`, validated immediately.
400    pub fn with_max_correctors(mut self, n: usize) -> Result<Self, OptionsError> {
401        if n == 0 {
402            return Err(OptionsError { field: "ipm.max_correctors", reason: "must be >= 1" });
403        }
404        self.max_correctors = n;
405        Ok(self)
406    }
407}
408
409// ---- SolverOptions ----------------------------------------------------
410
411/// Default clamp threshold for micro-values in solver output.
412pub const DEFAULT_CLAMP_TOL: f64 = 1e-14;
413
414/// Solver configuration.
415///
416/// Controls tolerances, iteration limits, refactorisation frequency, and
417/// algorithm selection.  `Default` uses values from `tolerances.rs`.
418///
419/// ## Validation
420///
421/// Call [`SolverOptions::validate`] (or use builder methods) before solving
422/// to catch invalid values (NaN, zero, negative tolerances, etc.) early.
423///
424/// ## Solver-specific parameters
425///
426/// Use the [`SolverOptions::ipm`] sub-struct for IPM-specific settings.
427#[derive(Debug, Clone)]
428pub struct SolverOptions {
429    // --- Common ---
430    /// Simplex primal feasibility / optimality threshold.  Default: `PIVOT_TOL`.
431    pub primal_tol: f64,
432    /// Max eta-file count (refactorisation threshold).  0 = auto (from problem size).
433    pub max_etas: usize,
434    /// Micro-value clamp threshold.  Default: [`DEFAULT_CLAMP_TOL`].
435    pub clamp_tol: f64,
436    /// Simplex algorithm selection.  Default: `Auto`.
437    pub simplex_method: SimplexMethod,
438    /// Dual feasibility threshold.  Default: `PIVOT_TOL`.
439    pub dual_tol: f64,
440    /// Dual simplex leaving strategy.  Default: `MostInfeasible`.
441    pub dual_pricing: DualPricing,
442    /// Enable Bound-Flipping Ratio Test (Maros 2003 §7.6) in `dual_advanced`.
443    /// Runtime override: `BOUND_FLIP_DISABLE=1`.
444    pub enable_bound_flipping: bool,
445    /// LP warm-start basis.  `None` = cold start.
446    pub warm_start: Option<WarmStartBasis>,
447    /// QP IP-PMM interior-point warm start for B&B node transfer.
448    pub warm_start_qp: Option<QpWarmStart>,
449    /// Extended LP warm start; takes priority over `warm_start`.
450    pub warm_start_lp: Option<LpWarmStart>,
451    /// Reconstruct `warm_start_basis` after postsolve.  Default: `false`.
452    ///
453    /// When presolve reduces the problem the reduced-LP basis indices are
454    /// invalid for the original LP.  `true` triggers basis reconstruction at
455    /// postsolve exit (LTSF crash + solution refinement).  Opt-in only.
456    ///
457    /// When presolve is skipped or the problem was not reduced, the simplex
458    /// basis is cloned directly regardless of this flag.
459    pub recover_warm_start_basis: bool,
460    /// Apply simplex crash basis on cold LP starts.  Ignored when
461    /// `warm_start` / `warm_start_lp` is set.
462    /// Runtime override: `LP_CRASH_DUAL_ADV_DISABLE=1` (Big-M path only).
463    pub use_lp_crash_basis: bool,
464    /// Enable presolve.  Default: `true`.
465    pub presolve: bool,
466    /// Timeout in seconds.  `None` = unlimited.
467    pub timeout_secs: Option<f64>,
468    /// Shared cancellation flag (internal use).
469    pub(crate) cancel_flag: Option<Arc<AtomicBool>>,
470    /// Solve deadline computed from `timeout_secs` at solve entry (internal use).
471    pub(crate) deadline: Option<Instant>,
472
473    // --- Ruiz scaling ---
474    /// Apply Ruiz equilibration scaling before IPM.  Default: `true`.
475    pub use_ruiz_scaling: bool,
476
477    // --- Tolerance abstraction ---
478    /// Convergence accuracy level.  `None` = use `ipm.eps` directly.
479    ///
480    /// When `Some(_)`, each solver derives eps from this; `ipm.eps` is ignored.
481    pub tolerance: Option<Tolerance>,
482
483    // --- Solver-specific ---
484    /// IPM-specific options.
485    pub ipm: IpmOptions,
486
487    /// Multi-start local search config.  `None` (default) = disabled.
488    pub multistart: Option<MultiStartConfig>,
489
490    /// Spatial B&B global optimisation config.  `None` (default) = disabled.
491    /// Only consumed by explicit `solve_qp_global` calls.
492    pub global_optimization: Option<GlobalOptimizationConfig>,
493
494    /// Thread budget for all solver paths (LP / QP / multistart).
495    ///
496    /// Default = 1 (serial; no contention with external bench workers).
497    ///
498    /// - **QP** (`threads >= 2`): enables faer parallel sparse LDL on the KKT system.
499    /// - **LP simplex** (`threads >= 2`): no effect.
500    /// - **Multistart** (`threads >= 2`): `min(n_starts, threads)` parallel degree;
501    ///   inner solves forced to `threads = 1`.
502    pub threads: usize,
503
504    /// Reference optimal objective for early-exit.
505    ///
506    /// When `Some(ref_obj)`, returns `Optimal` as soon as
507    /// `|obj − ref_obj| / (1 + |ref_obj|) < OBJ_MATCH_REL_TOL`.
508    /// Used by bench harnesses.  `None` = no early-exit.
509    pub known_optimal_obj: Option<f64>,
510}
511
512/// Auto-compute `max_etas` from problem size.
513///
514/// Small problems (m < 1000): 20; larger: m / 50.
515pub fn default_max_etas(m: usize) -> usize {
516    (m / 50).max(20)
517}
518
519/// Phase I retry cap: guards against degenerate problems that loop with an
520/// identical basis in `revised_simplex_core`.
521pub const MAX_PHASE1_RETRIES: usize = 8;
522
523impl Default for SolverOptions {
524    fn default() -> Self {
525        Self {
526            primal_tol: PIVOT_TOL,
527            max_etas: 0,
528            clamp_tol: DEFAULT_CLAMP_TOL,
529            simplex_method: SimplexMethod::Auto,
530            dual_tol: PIVOT_TOL,
531            dual_pricing: DualPricing::default(),
532            enable_bound_flipping: false,
533            warm_start: None,
534            warm_start_qp: None,
535            warm_start_lp: None,
536            recover_warm_start_basis: false,
537            use_lp_crash_basis: true,
538            presolve: true,
539            timeout_secs: None,
540            cancel_flag: None,
541            deadline: None,
542            use_ruiz_scaling: true,
543            tolerance: None,
544            ipm: IpmOptions::default(),
545            multistart: None,
546            global_optimization: None,
547            threads: 1,
548            known_optimal_obj: None,
549        }
550    }
551}
552
553impl SolverOptions {
554    /// Effective IPM eps: derived from `tolerance` if set, otherwise `ipm.eps`.
555    pub fn ipm_eps(&self) -> f64 {
556        match self.tolerance {
557            Some(Tolerance::High)      => TOLERANCE_HIGH_EPS,
558            Some(Tolerance::Medium)    => TOLERANCE_MEDIUM_EPS,
559            Some(Tolerance::Fast)      => TOLERANCE_FAST_EPS,
560            Some(Tolerance::Custom(v)) => v,
561            None => self.ipm.eps,
562        }
563    }
564
565    /// Validate all option fields.
566    ///
567    /// Returns the first `Err` encountered, in field declaration order.
568    /// Called by public solver entry points (`solve_qp_with`, `solve_qp_global`,
569    /// `solve_qp_multistart`, `solve_milp`, `solve_miqp`, `simplex::solve_with`)
570    /// before starting work; invalid options cause the entry to return
571    /// [`crate::problem::SolveStatus::NumericalError`] rather than propagating
572    /// bad values into the solver core.
573    ///
574    /// Invalid conditions:
575    /// - `primal_tol` / `dual_tol`: non-finite or <= 0
576    /// - `clamp_tol`: non-finite or < 0 (0 is allowed)
577    /// - `threads`: 0
578    /// - `timeout_secs`: `Some(v)` where v is non-finite or < 0
579    /// - `tolerance`: `Custom(v)` where v is non-finite or <= 0
580    /// - Any field in [`IpmOptions`]
581    pub fn validate(&self) -> Result<(), OptionsError> {
582        if !self.primal_tol.is_finite() || self.primal_tol <= 0.0 {
583            return Err(OptionsError { field: "primal_tol", reason: "must be finite and > 0" });
584        }
585        if !self.dual_tol.is_finite() || self.dual_tol <= 0.0 {
586            return Err(OptionsError { field: "dual_tol", reason: "must be finite and > 0" });
587        }
588        if !self.clamp_tol.is_finite() || self.clamp_tol < 0.0 {
589            return Err(OptionsError { field: "clamp_tol", reason: "must be finite and >= 0" });
590        }
591        if self.threads == 0 {
592            return Err(OptionsError { field: "threads", reason: "must be >= 1" });
593        }
594        if let Some(t) = self.timeout_secs {
595            if !t.is_finite() || t < 0.0 {
596                return Err(OptionsError { field: "timeout_secs", reason: "must be finite and >= 0" });
597            }
598        }
599        if let Some(Tolerance::Custom(v)) = self.tolerance {
600            if !v.is_finite() || v <= 0.0 {
601                return Err(OptionsError {
602                    field: "tolerance.Custom",
603                    reason: "must be finite and > 0",
604                });
605            }
606        }
607        self.ipm.validate()?;
608        Ok(())
609    }
610
611    /// Builder: set `timeout_secs`, validated immediately.
612    pub fn with_timeout(mut self, secs: f64) -> Result<Self, OptionsError> {
613        if !secs.is_finite() || secs < 0.0 {
614            return Err(OptionsError { field: "timeout_secs", reason: "must be finite and >= 0" });
615        }
616        self.timeout_secs = Some(secs);
617        Ok(self)
618    }
619
620    /// Builder: set `threads`, validated immediately.
621    pub fn with_threads(mut self, n: usize) -> Result<Self, OptionsError> {
622        if n == 0 {
623            return Err(OptionsError { field: "threads", reason: "must be >= 1" });
624        }
625        self.threads = n;
626        Ok(self)
627    }
628
629    /// Builder: set `tolerance`, validated immediately.
630    ///
631    /// `Tolerance::Custom(v)` requires v to be finite and > 0; other variants
632    /// are always accepted.
633    pub fn with_tolerance(mut self, tol: Tolerance) -> Result<Self, OptionsError> {
634        if let Tolerance::Custom(v) = tol {
635            if !v.is_finite() || v <= 0.0 {
636                return Err(OptionsError {
637                    field: "tolerance.Custom",
638                    reason: "must be finite and > 0",
639                });
640            }
641        }
642        self.tolerance = Some(tol);
643        Ok(self)
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    // ---- Tolerance translation -------------------------------------------
652
653    #[test]
654    fn test_tolerance_translation() {
655        // Table-driven: (tolerance setting, expected ipm_eps)
656        let cases: &[(Option<Tolerance>, f64)] = &[
657            (Some(Tolerance::High),         TOLERANCE_HIGH_EPS),
658            (Some(Tolerance::Medium),       TOLERANCE_MEDIUM_EPS),
659            (Some(Tolerance::Fast),         TOLERANCE_FAST_EPS),
660            (Some(Tolerance::Custom(1e-5)), 1e-5),
661            (None,                          DEFAULT_IPM_EPS), // uses ipm.eps default
662        ];
663        for (tol, expected) in cases {
664            let opts = SolverOptions { tolerance: *tol, ..Default::default() };
665            assert_eq!(opts.ipm_eps(), *expected, "tolerance = {:?}", tol);
666        }
667    }
668
669    #[test]
670    fn test_tolerance_fast_is_looser_than_medium() {
671        // Fast must be coarser (larger eps) than Medium; otherwise the name is misleading.
672        assert!(TOLERANCE_FAST_EPS > TOLERANCE_MEDIUM_EPS);
673        assert!(TOLERANCE_MEDIUM_EPS > TOLERANCE_HIGH_EPS);
674    }
675
676    // ---- IpmOptions::validate -------------------------------------------
677
678    #[test]
679    fn test_ipm_validate_defaults_ok() {
680        assert!(IpmOptions::default().validate().is_ok());
681    }
682
683    #[test]
684    fn test_ipm_validate_eps() {
685        for bad in [0.0_f64, -1e-6, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
686            let o = IpmOptions { eps: bad, ..Default::default() };
687            assert!(o.validate().is_err(), "eps={bad} should be invalid");
688        }
689        // boundary: smallest positive finite value is valid
690        let o = IpmOptions { eps: f64::MIN_POSITIVE, ..Default::default() };
691        assert!(o.validate().is_ok());
692    }
693
694    #[test]
695    fn test_ipm_validate_delta_min() {
696        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
697            let o = IpmOptions { delta_min: bad, ..Default::default() };
698            assert!(o.validate().is_err(), "delta_min={bad} should be invalid");
699        }
700    }
701
702    #[test]
703    fn test_ipm_validate_delta_p_init() {
704        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
705            let o = IpmOptions { delta_p_init: bad, ..Default::default() };
706            assert!(o.validate().is_err(), "delta_p_init={bad} should be invalid");
707        }
708    }
709
710    #[test]
711    fn test_ipm_validate_delta_d_init() {
712        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
713            let o = IpmOptions { delta_d_init: bad, ..Default::default() };
714            assert!(o.validate().is_err(), "delta_d_init={bad} should be invalid");
715        }
716    }
717
718    #[test]
719    fn test_ipm_validate_max_correctors() {
720        let o = IpmOptions { max_correctors: 0, ..Default::default() };
721        assert!(o.validate().is_err(), "max_correctors=0 should be invalid");
722        let o = IpmOptions { max_correctors: 1, ..Default::default() };
723        assert!(o.validate().is_ok());
724    }
725
726    // ---- IpmOptions builders --------------------------------------------
727
728    #[test]
729    fn test_ipm_builder_with_eps() {
730        assert!(IpmOptions::default().with_eps(1e-4).is_ok());
731        assert!(IpmOptions::default().with_eps(f64::MIN_POSITIVE).is_ok());
732        for bad in [0.0_f64, -1.0, f64::NAN, f64::INFINITY] {
733            assert!(IpmOptions::default().with_eps(bad).is_err(), "with_eps({bad}) should err");
734        }
735    }
736
737    #[test]
738    fn test_ipm_builder_with_max_correctors() {
739        assert!(IpmOptions::default().with_max_correctors(1).is_ok());
740        assert!(IpmOptions::default().with_max_correctors(10).is_ok());
741        assert!(IpmOptions::default().with_max_correctors(0).is_err());
742    }
743
744    // ---- SolverOptions::validate ----------------------------------------
745
746    #[test]
747    fn test_solver_validate_defaults_ok() {
748        assert!(SolverOptions::default().validate().is_ok());
749    }
750
751    #[test]
752    fn test_solver_validate_primal_tol() {
753        for bad in [0.0_f64, -1e-8, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
754            let o = SolverOptions { primal_tol: bad, ..Default::default() };
755            assert!(o.validate().is_err(), "primal_tol={bad}");
756        }
757        let o = SolverOptions { primal_tol: f64::MIN_POSITIVE, ..Default::default() };
758        assert!(o.validate().is_ok());
759    }
760
761    #[test]
762    fn test_solver_validate_dual_tol() {
763        for bad in [0.0_f64, -1e-8, f64::NAN, f64::INFINITY] {
764            let o = SolverOptions { dual_tol: bad, ..Default::default() };
765            assert!(o.validate().is_err(), "dual_tol={bad}");
766        }
767    }
768
769    #[test]
770    fn test_solver_validate_clamp_tol() {
771        // 0.0 is valid (no clamping)
772        let o = SolverOptions { clamp_tol: 0.0, ..Default::default() };
773        assert!(o.validate().is_ok(), "clamp_tol=0 should be ok");
774        for bad in [-1.0_f64, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
775            let o = SolverOptions { clamp_tol: bad, ..Default::default() };
776            assert!(o.validate().is_err(), "clamp_tol={bad}");
777        }
778    }
779
780    #[test]
781    fn test_solver_validate_threads() {
782        let o = SolverOptions { threads: 0, ..Default::default() };
783        assert!(o.validate().is_err(), "threads=0");
784        for ok in [1_usize, 2, 8, usize::MAX] {
785            let o = SolverOptions { threads: ok, ..Default::default() };
786            assert!(o.validate().is_ok(), "threads={ok}");
787        }
788    }
789
790    #[test]
791    fn test_solver_validate_timeout_secs() {
792        // None is always valid
793        assert!(SolverOptions { timeout_secs: None, ..Default::default() }.validate().is_ok());
794        // non-negative finite: valid (0.0 = immediately-expired deadline)
795        for ok in [0.0_f64, 0.001, 1.0, 1000.0] {
796            let o = SolverOptions { timeout_secs: Some(ok), ..Default::default() };
797            assert!(o.validate().is_ok(), "timeout_secs=Some({ok}) must be valid");
798        }
799        // invalid: negative, NaN, or infinite
800        for bad in [-1.0_f64, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
801            let o = SolverOptions { timeout_secs: Some(bad), ..Default::default() };
802            assert!(o.validate().is_err(), "timeout_secs=Some({bad})");
803        }
804    }
805
806    #[test]
807    fn test_solver_validate_tolerance_custom() {
808        // Non-Custom variants are always valid
809        for tol in [Tolerance::High, Tolerance::Medium, Tolerance::Fast] {
810            let o = SolverOptions { tolerance: Some(tol), ..Default::default() };
811            assert!(o.validate().is_ok(), "tolerance={tol:?}");
812        }
813        // Custom: valid
814        let o = SolverOptions { tolerance: Some(Tolerance::Custom(1e-5)), ..Default::default() };
815        assert!(o.validate().is_ok());
816        // Custom: invalid
817        for bad in [0.0_f64, -1e-4, f64::NAN, f64::INFINITY] {
818            let o = SolverOptions { tolerance: Some(Tolerance::Custom(bad)), ..Default::default() };
819            assert!(o.validate().is_err(), "Tolerance::Custom({bad})");
820        }
821    }
822
823    #[test]
824    fn test_solver_validate_propagates_ipm() {
825        // SolverOptions::validate must propagate IpmOptions::validate errors.
826        let o = SolverOptions {
827            ipm: IpmOptions { eps: 0.0, ..Default::default() },
828            ..Default::default()
829        };
830        assert!(o.validate().is_err(), "ipm.eps=0 must propagate");
831
832        let o = SolverOptions {
833            ipm: IpmOptions { max_correctors: 0, ..Default::default() },
834            ..Default::default()
835        };
836        assert!(o.validate().is_err(), "ipm.max_correctors=0 must propagate");
837    }
838
839    // ---- SolverOptions builders -----------------------------------------
840
841    #[test]
842    fn test_solver_builder_with_timeout() {
843        assert!(SolverOptions::default().with_timeout(10.0).is_ok());
844        assert!(SolverOptions::default().with_timeout(0.001).is_ok());
845        assert!(SolverOptions::default().with_timeout(0.0).is_ok(), "0.0 = immediately-expired deadline");
846        for bad in [-1.0_f64, f64::NAN, f64::INFINITY] {
847            assert!(SolverOptions::default().with_timeout(bad).is_err(), "with_timeout({bad})");
848        }
849        // Result carries the set value
850        let o = SolverOptions::default().with_timeout(5.0).unwrap();
851        assert_eq!(o.timeout_secs, Some(5.0));
852    }
853
854    #[test]
855    fn test_solver_builder_with_threads() {
856        assert!(SolverOptions::default().with_threads(1).is_ok());
857        assert!(SolverOptions::default().with_threads(8).is_ok());
858        assert!(SolverOptions::default().with_threads(0).is_err());
859        let o = SolverOptions::default().with_threads(4).unwrap();
860        assert_eq!(o.threads, 4);
861    }
862
863    #[test]
864    fn test_solver_builder_with_tolerance() {
865        assert!(SolverOptions::default().with_tolerance(Tolerance::High).is_ok());
866        assert!(SolverOptions::default().with_tolerance(Tolerance::Medium).is_ok());
867        assert!(SolverOptions::default().with_tolerance(Tolerance::Fast).is_ok());
868        assert!(SolverOptions::default().with_tolerance(Tolerance::Custom(1e-5)).is_ok());
869        for bad in [0.0_f64, -1e-4, f64::NAN, f64::INFINITY] {
870            assert!(
871                SolverOptions::default().with_tolerance(Tolerance::Custom(bad)).is_err(),
872                "with_tolerance(Custom({bad}))"
873            );
874        }
875        let o = SolverOptions::default().with_tolerance(Tolerance::Fast).unwrap();
876        assert_eq!(o.tolerance, Some(Tolerance::Fast));
877    }
878
879    // ---- OptionsError display -------------------------------------------
880
881    #[test]
882    fn test_options_error_display() {
883        let e = OptionsError { field: "ipm.eps", reason: "must be finite and > 0" };
884        let s = e.to_string();
885        assert!(s.contains("ipm.eps"), "display: {s}");
886        assert!(s.contains("finite"), "display: {s}");
887    }
888}