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