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