Skip to main content

cobre_solver/backends/highs/
solver.rs

1//! The [`HighsSolver`] handle wrapper, its lifecycle/solve primitives, and the
2//! `highs_version` free function.
3//!
4//! Owns the `HighsSolver` struct definition, the `unsafe impl Send`, the
5//! construction/configuration/solve helpers, the warm-start `solve_inner`
6//! orchestration (determinism-sensitive), the `Drop` handle teardown, the
7//! `highs_version` query, and the `test-support` accessor impl. The escalation
8//! ladder (`retry`) and the `SolverInterface` impl (`interface`) are additional
9//! `impl HighsSolver` blocks that reach this struct's fields and helpers via the
10//! child-module hierarchy.
11
12use std::os::raw::c_void;
13use std::time::Instant;
14
15use super::config::{HighsProfile, default_options};
16use crate::{
17    DEFAULT_PROFILE_HEURISTIC_SENTINEL, DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL, ffi,
18    types::{SolutionView, SolverError, SolverStatistics},
19};
20
21/// `HiGHS` LP solver instance implementing [`SolverInterface`](crate::SolverInterface).
22///
23/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
24/// extraction, scratch i32 index conversion, and statistics accumulation.
25///
26/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
27/// when the instance is dropped.
28///
29/// # Example
30///
31/// ```rust
32/// use cobre_solver::{HighsSolver, SolverInterface};
33///
34/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
35/// assert_eq!(solver.name(), "HiGHS");
36/// ```
37pub struct HighsSolver {
38    /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
39    pub(super) handle: *mut c_void,
40    /// Pre-allocated buffer for primal column values extracted after each solve.
41    /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
42    pub(super) col_value: Vec<f64>,
43    /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
44    /// Resized in `load_model`.
45    pub(super) col_dual: Vec<f64>,
46    /// Pre-allocated buffer for row primal values (constraint activity).
47    /// Resized in `load_model`.
48    pub(super) row_value: Vec<f64>,
49    /// Pre-allocated buffer for row dual multipliers (shadow prices).
50    /// Resized in `load_model`.
51    pub(super) row_dual: Vec<f64>,
52    /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
53    /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
54    /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
55    pub(super) scratch_i32: Vec<i32>,
56    /// Pre-allocated i32 buffer for column basis status codes.
57    /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
58    /// Resized in `load_model` to `num_cols`; never shrunk.
59    pub(super) basis_col_i32: Vec<i32>,
60    /// Pre-allocated i32 buffer for row basis status codes.
61    /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
62    /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
63    pub(super) basis_row_i32: Vec<i32>,
64    /// Scratch buffer for dual-ray extraction in `interpret_terminal_status` (dual).
65    /// Grown lazily to `num_rows` via `resize`; contents are discarded after classification.
66    /// Retained across calls so repeated non-optimal solves do not re-allocate.
67    pub(super) terminal_status_dual_scratch: Vec<f64>,
68    /// Scratch buffer for primal-ray extraction in `interpret_terminal_status` (primal).
69    /// Grown lazily to `num_cols` via `resize`; contents are discarded after classification.
70    /// Retained across calls so repeated non-optimal solves do not re-allocate.
71    pub(super) terminal_status_primal_scratch: Vec<f64>,
72    /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
73    pub(super) num_cols: usize,
74    /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
75    pub(super) num_rows: usize,
76    /// Whether a model is currently loaded. Set to `true` in `load_model`,
77    /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
78    pub(super) has_model: bool,
79    /// Accumulated solver statistics. Counters grow monotonically from zero;
80    /// not reset by `reset()`.
81    pub(super) stats: SolverStatistics,
82    /// Cached solver profile applied by the last `set_*_profile` call.
83    ///
84    /// Initialised to `HighsProfile::default()` at construction, which
85    /// preserves the historical hardcoded behaviour bit-for-bit. Updated by
86    /// the four `SolverInterface` profile setter methods; read by
87    /// `set_iteration_limits` on every solve attempt.
88    pub(super) current_profile: HighsProfile,
89}
90
91// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
92// handle is not thread-safe for concurrent access, but exclusive ownership is
93// maintained at all times -- exactly one `HighsSolver` instance owns each
94// handle and no shared references to the handle exist. Transferring the
95// `HighsSolver` to another thread (via `Send`) is safe because there is no
96// concurrent access; the new thread has exclusive ownership. `Sync` is
97// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
98unsafe impl Send for HighsSolver {}
99
100impl HighsSolver {
101    /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
102    ///
103    /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
104    /// the thirteen default options defined in `HiGHS` Implementation SS4.1:
105    ///
106    /// | Option                                      | Value       | Type   |
107    /// |---------------------------------------------|-------------|--------|
108    /// | `solver`                                    | `"simplex"` | string |
109    /// | `simplex_strategy`                          | `1`         | int    |
110    /// | `simplex_scale_strategy`                    | `0`         | int    |
111    /// | `presolve`                                  | `"on"`      | string |
112    /// | `parallel`                                  | `"off"`     | string |
113    /// | `output_flag`                               | `0`         | bool   |
114    /// | `primal_feasibility_tolerance`              | `1e-9`      | double |
115    /// | `dual_feasibility_tolerance`                | `1e-9`      | double |
116    /// | `simplex_dual_edge_weight_strategy`         | `1`         | int    |
117    /// | `dual_simplex_cost_perturbation_multiplier` | `0.0`       | double |
118    /// | `simplex_initial_condition_check`           | `0`         | bool   |
119    /// | `simplex_price_strategy`                    | `1`         | int    |
120    /// | `rebuild_refactor_solution_error_tolerance` | `1e-6`      | double |
121    ///
122    /// # Errors
123    ///
124    /// Returns `Err(SolverError::InternalError { .. })` if:
125    /// - `cobre_highs_create()` returns a null pointer.
126    /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
127    ///
128    /// In both failure cases the `HiGHS` handle is destroyed before returning to
129    /// prevent a resource leak.
130    pub fn new() -> Result<Self, SolverError> {
131        // SAFETY: `cobre_highs_create` is a C function with no preconditions.
132        // It allocates and returns a new `HiGHS` instance, or null on allocation
133        // failure. The returned pointer is opaque and must be passed back to
134        // `HiGHS` API functions.
135        let handle = unsafe { ffi::cobre_highs_create() };
136
137        if handle.is_null() {
138            return Err(SolverError::InternalError {
139                message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
140                error_code: None,
141            });
142        }
143
144        // Apply performance-tuned configuration. On any failure, destroy the
145        // handle before returning to prevent a resource leak.
146        if let Err(e) = Self::apply_default_config(handle) {
147            // SAFETY: `handle` is a valid, non-null pointer obtained from
148            // `cobre_highs_create()` in this same function. It has not been
149            // passed to `cobre_highs_destroy()` yet. After this call, `handle`
150            // must not be used again -- this function returns immediately with Err.
151            unsafe { ffi::cobre_highs_destroy(handle) };
152            return Err(e);
153        }
154
155        Ok(Self {
156            handle,
157            col_value: Vec::new(),
158            col_dual: Vec::new(),
159            row_value: Vec::new(),
160            row_dual: Vec::new(),
161            scratch_i32: Vec::new(),
162            basis_col_i32: Vec::new(),
163            basis_row_i32: Vec::new(),
164            terminal_status_dual_scratch: Vec::new(),
165            terminal_status_primal_scratch: Vec::new(),
166            num_cols: 0,
167            num_rows: 0,
168            has_model: false,
169            stats: SolverStatistics {
170                retry_level_histogram: vec![0u64; 12],
171                ..SolverStatistics::default()
172            },
173            current_profile: HighsProfile::default(),
174        })
175    }
176
177    /// Applies the thirteen performance-tuned `HiGHS` configuration options.
178    ///
179    /// Called once during construction. Returns `Ok(())` if all options are set
180    /// successfully, or `Err(SolverError::InternalError)` with the failing
181    /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
182    fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
183        for opt in &default_options() {
184            // SAFETY: `handle` is a valid, non-null HiGHS pointer.
185            let status = unsafe { opt.apply(handle) };
186            if status == ffi::HIGHS_STATUS_ERROR {
187                return Err(SolverError::InternalError {
188                    message: format!(
189                        "HiGHS configuration failed: {}",
190                        opt.name.to_str().unwrap_or("?")
191                    ),
192                    error_code: Some(status),
193                });
194            }
195        }
196        Ok(())
197    }
198
199    /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
200    /// a [`SolutionView`] borrowing directly from those buffers.
201    ///
202    /// The returned view borrows solver-internal buffers and is valid until the next
203    /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
204    /// canonical sign convention (per Solver Abstraction SS8).
205    pub(super) fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
206        // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
207        let status = unsafe {
208            ffi::cobre_highs_get_solution(
209                self.handle,
210                self.col_value.as_mut_ptr(),
211                self.col_dual.as_mut_ptr(),
212                self.row_value.as_mut_ptr(),
213                self.row_dual.as_mut_ptr(),
214            )
215        };
216        // HiGHS documentation guarantees `cobre_highs_get_solution` returns
217        // non-ERROR status after `OPTIMAL` model status; this is a
218        // debug-build-only invariant check.
219        debug_assert_ne!(
220            status,
221            ffi::HIGHS_STATUS_ERROR,
222            "cobre_highs_get_solution failed after optimal solve; HiGHS invariant violation"
223        );
224
225        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
226        let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
227
228        // SAFETY: iteration count is non-negative so cast is safe.
229        #[allow(clippy::cast_sign_loss)]
230        let iterations =
231            unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
232
233        SolutionView {
234            objective,
235            primal: &self.col_value[..self.num_cols],
236            dual: &self.row_dual[..self.num_rows],
237            reduced_costs: &self.col_dual[..self.num_cols],
238            iterations,
239            solve_time_seconds,
240        }
241    }
242
243    /// Re-applies the current profile's feasibility tolerances to the `HiGHS` instance.
244    ///
245    /// Called immediately after `restore_default_settings()` in the retry-escalation
246    /// finalization path so that `HiGHS` state and `current_profile` remain in sync.
247    /// `restore_default_settings` resets the tolerances to the hardcoded table values
248    /// (`1e-9`); this helper layers the caller's profile values on top.
249    ///
250    /// The iteration limits are not re-applied here because `restore_iteration_limits`
251    /// always follows immediately and sets them to `i32::MAX` (unconstrained for the
252    /// post-retry default-attempt path).
253    pub(super) fn apply_profile_tolerances(&mut self) {
254        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained from
255        // `cobre_highs_create()`. Option names are static C string literals with no
256        // retained pointer after the call returns.
257        unsafe {
258            ffi::cobre_highs_set_double_option(
259                self.handle,
260                c"primal_feasibility_tolerance".as_ptr(),
261                self.current_profile.primal_feasibility_tolerance,
262            );
263            ffi::cobre_highs_set_double_option(
264                self.handle,
265                c"dual_feasibility_tolerance".as_ptr(),
266                self.current_profile.dual_feasibility_tolerance,
267            );
268            // Also re-apply the algorithmic strategy int options. Post-retry
269            // `restore_default_settings` resets these to HiGHS defaults; the
270            // profile values must be reinstalled before the default-attempt
271            // path runs so backward-tuned profiles survive the retry boundary.
272            ffi::cobre_highs_set_int_option(
273                self.handle,
274                c"simplex_dual_edge_weight_strategy".as_ptr(),
275                self.current_profile.simplex_dual_edge_weight_strategy,
276            );
277            ffi::cobre_highs_set_int_option(
278                self.handle,
279                c"simplex_scale_strategy".as_ptr(),
280                self.current_profile.simplex_scale_strategy,
281            );
282            ffi::cobre_highs_set_int_option(
283                self.handle,
284                c"simplex_price_strategy".as_ptr(),
285                self.current_profile.simplex_price_strategy,
286            );
287        }
288    }
289
290    /// Restores default options after retry escalation.
291    ///
292    /// Status codes are checked via `debug_assert!` to catch programming
293    /// errors during development (e.g., invalid option name). In release
294    /// builds, failures are silently ignored since we are already on the
295    /// recovery path.
296    pub(super) fn restore_default_settings(&mut self) {
297        for opt in &default_options() {
298            // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
299            let status = unsafe { opt.apply(self.handle) };
300            debug_assert_eq!(
301                status,
302                ffi::HIGHS_STATUS_OK,
303                "restore_default_settings: option {:?} failed with status {status}",
304                opt.name,
305            );
306        }
307    }
308
309    /// Runs the solver once and returns the raw `HiGHS` model status.
310    pub(super) fn run_once(&mut self) -> i32 {
311        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
312        let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
313        if run_status == ffi::HIGHS_STATUS_ERROR {
314            return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
315        }
316        // SAFETY: same.
317        unsafe { ffi::cobre_highs_get_model_status(self.handle) }
318    }
319
320    /// Sets per-solve iteration limits before a `run_once()` call.
321    ///
322    /// Simplex cap: if `current_profile.simplex_iteration_limit` equals
323    /// [`DEFAULT_PROFILE_HEURISTIC_SENTINEL`] (`0`), the historical heuristic
324    /// `max(100_000, 50 × num_cols)` is used. Any non-zero profile value is
325    /// applied verbatim (clamped to `i32::MAX` for the FFI call).
326    ///
327    /// IPM cap: if `current_profile.ipm_iteration_limit` equals
328    /// [`DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL`] (`0`), `i32::MAX` is sent to
329    /// `HiGHS` (no cap). Any positive value is applied verbatim (clamped to
330    /// `i32::MAX` for the FFI call). The `Default` value is `10_000`, so
331    /// existing callers see no behavioural change.
332    ///
333    /// **Note on `time_limit`**: `HiGHS` tracks elapsed time cumulatively from
334    /// instance creation, not per-`run()` call — neither `clear_solver()` nor
335    /// option changes reset the internal timer. This makes `time_limit`
336    /// unusable for the scenario-loop pattern (thousands of solves per
337    /// instance). Wall-clock measurement via `Instant` is used instead for
338    /// time-based budget management.
339    pub(super) fn set_iteration_limits(&mut self) {
340        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
341        let simplex_iter_limit: i32 =
342            if self.current_profile.simplex_iteration_limit == DEFAULT_PROFILE_HEURISTIC_SENTINEL {
343                // Heuristic fallback: scale with LP size to avoid runaway cycling.
344                let heuristic = self.num_cols.saturating_mul(50).max(100_000);
345                // `heuristic` is bounded by `usize::MAX`, but realistic LP sizes
346                // are well below `i32::MAX` (≈2.1 × 10^9 cols). Clamp defensively.
347                (heuristic.min(i32::MAX as usize)) as i32
348            } else {
349                // Profile literal value: apply verbatim, clamped for FFI cast.
350                // `.min(i32::MAX as u32)` ensures the value fits; the cast cannot wrap.
351                (self
352                    .current_profile
353                    .simplex_iteration_limit
354                    .min(i32::MAX as u32)) as i32
355            };
356
357        // IPM cap: 0 is the "unbounded" sentinel per trait contract; map it to
358        // i32::MAX so HiGHS does not interpret 0 as "no iterations allowed".
359        // Any positive value is applied verbatim (clamped for the FFI cast).
360        #[allow(clippy::cast_possible_wrap)]
361        let ipm_iter_limit: i32 =
362            if self.current_profile.ipm_iteration_limit == DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL {
363                i32::MAX // "unbounded" per trait contract
364            } else {
365                (self
366                    .current_profile
367                    .ipm_iteration_limit
368                    .min(i32::MAX as u32)) as i32
369            };
370
371        // SAFETY: handle is valid non-null HiGHS pointer; option names are
372        // static C strings with no retained pointers.
373        unsafe {
374            ffi::cobre_highs_set_int_option(
375                self.handle,
376                c"simplex_iteration_limit".as_ptr(),
377                simplex_iter_limit,
378            );
379            ffi::cobre_highs_set_int_option(
380                self.handle,
381                c"ipm_iteration_limit".as_ptr(),
382                ipm_iter_limit,
383            );
384        }
385    }
386
387    /// Restores iteration limits to their unconstrained defaults.
388    ///
389    /// Called after `retry_escalation` completes (regardless of outcome).
390    pub(super) fn restore_iteration_limits(&mut self) {
391        // SAFETY: handle is valid non-null HiGHS pointer.
392        unsafe {
393            ffi::cobre_highs_set_int_option(
394                self.handle,
395                c"simplex_iteration_limit".as_ptr(),
396                i32::MAX,
397            );
398            ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), i32::MAX);
399        }
400    }
401
402    /// Interprets a non-optimal status as a terminal `SolverError`.
403    ///
404    /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
405    /// or `Some(error)` for terminal statuses.
406    pub(super) fn interpret_terminal_status(
407        &mut self,
408        status: i32,
409        solve_time_seconds: f64,
410    ) -> Option<SolverError> {
411        match status {
412            ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
413                // Caller should have handled optimal before reaching here.
414                None
415            }
416            ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
417            ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
418                // Probe for a dual ray to classify as Infeasible, then a primal
419                // ray to classify as Unbounded. The ray values are not stored in
420                // the error -- only the classification matters.
421                //
422                // `num_rows` and `num_cols` are up-to-date because `load_model`
423                // and `add_rows` always update them before any solve that could
424                // reach this branch. The `resize` below matches the exact count
425                // that HiGHS writes into the buffer.
426                let mut has_dual_ray: i32 = 0;
427                self.terminal_status_dual_scratch.resize(self.num_rows, 0.0);
428                // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
429                // `terminal_status_dual_scratch` has been resized to at least
430                // `self.num_rows` elements; HiGHS writes exactly `num_rows` values.
431                let dual_status = unsafe {
432                    ffi::cobre_highs_get_dual_ray(
433                        self.handle,
434                        &raw mut has_dual_ray,
435                        self.terminal_status_dual_scratch.as_mut_ptr(),
436                    )
437                };
438                if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
439                    return Some(SolverError::Infeasible);
440                }
441                let mut has_primal_ray: i32 = 0;
442                self.terminal_status_primal_scratch
443                    .resize(self.num_cols, 0.0);
444                // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
445                // `terminal_status_primal_scratch` has been resized to at least
446                // `self.num_cols` elements; HiGHS writes exactly `num_cols` values.
447                let primal_status = unsafe {
448                    ffi::cobre_highs_get_primal_ray(
449                        self.handle,
450                        &raw mut has_primal_ray,
451                        self.terminal_status_primal_scratch.as_mut_ptr(),
452                    )
453                };
454                if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
455                    return Some(SolverError::Unbounded);
456                }
457                Some(SolverError::Infeasible)
458            }
459            ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
460            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
461                elapsed_seconds: solve_time_seconds,
462            }),
463            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
464                // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
465                #[allow(clippy::cast_sign_loss)]
466                let iterations =
467                    unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
468                Some(SolverError::IterationLimit { iterations })
469            }
470            ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
471                // Signal to the caller that retry should continue.
472                None
473            }
474            other => Some(SolverError::InternalError {
475                message: format!("HiGHS returned unexpected model status {other}"),
476                error_code: Some(other),
477            }),
478        }
479    }
480
481    /// Converts `usize` indices to `i32` in the internal scratch buffer.
482    ///
483    /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
484    pub(super) fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
485        if source.len() > self.scratch_i32.len() {
486            self.scratch_i32.resize(source.len(), 0);
487        }
488        for (i, &v) in source.iter().enumerate() {
489            debug_assert!(
490                i32::try_from(v).is_ok(),
491                "usize index {v} overflows i32::MAX at position {i}"
492            );
493            // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
494            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
495            {
496                self.scratch_i32[i] = v as i32;
497            }
498        }
499        &self.scratch_i32[..source.len()]
500    }
501
502    /// Internal helper: run the simplex and update stats.
503    ///
504    /// Core simplex execution, called after (for warm-start) the basis has been
505    /// installed. `HiGHS` retains its internal simplex basis across consecutive
506    /// `solve_inner` calls on the same LP shape, which is the primary warm-start
507    /// mechanism for the backward pass. No `Highs_clearSolver` call is issued —
508    /// clearing the solver discards the retained basis and forfeits the warm start.
509    pub(super) fn solve_inner(&mut self) -> Result<SolutionView<'_>, SolverError> {
510        // Safeguard: apply iteration limits before the initial attempt.
511        // Time limits are NOT set here — HiGHS tracks time cumulatively from
512        // instance creation, so a per-solve time_limit would fire spuriously
513        // on long-running solver instances. Instead, wall-clock time is checked
514        // after run_once() to detect stuck solves.
515        self.set_iteration_limits();
516
517        let t0 = Instant::now();
518        let model_status = self.run_once();
519        let solve_time = t0.elapsed().as_secs_f64();
520
521        self.stats.solve_count += 1;
522
523        if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
524            // Read iteration count from FFI BEFORE establishing the shared borrow
525            // via extract_solution_view, so stats can be updated without violating
526            // the aliasing rules.
527            // SAFETY: handle is valid non-null HiGHS pointer.
528            #[allow(clippy::cast_sign_loss)]
529            let iterations =
530                unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
531            self.stats.success_count += 1;
532            self.stats.first_try_successes += 1;
533            self.stats.total_iterations += iterations;
534            self.stats.total_solve_time_seconds += solve_time;
535            self.restore_iteration_limits();
536            return Ok(self.extract_solution_view(solve_time));
537        }
538
539        // Check for a definitive terminal status (not a retry-able error).
540        // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
541        // on numerically difficult LPs with wide coefficient ranges. The retry
542        // escalation (especially presolve in the core sequence) often resolves these.
543        // ITERATION_LIMIT from the initial attempt is retryable — the retry
544        // sequence uses different strategies that may converge faster.
545        // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
546        // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
547        // in edge cases. Retry level 0 (cold restart) recovers from this.
548        // Wall-clock > 15s is also retryable — detects stuck initial solves.
549        let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
550        // INFEASIBLE from the initial attempt is retryable. A warm-started dual
551        // simplex can FALSELY report INFEASIBLE on numerically difficult LPs --
552        // the same warm-start fragility that surfaces as spurious UNBOUNDED or
553        // ITERATION_LIMIT (retried above). Routing INFEASIBLE through the
554        // escalation runs level 0 first, which clears the warm basis
555        // (`cobre_highs_clear_solver`) and re-solves cold. A *genuinely* infeasible
556        // LP is confirmed by that cold solve and the escalation stops immediately
557        // (INFEASIBLE is terminal inside `retry_escalation`), so a true infeasible
558        // still returns `Err(Infeasible)` -- only one extra cold solve. A
559        // warm-start-only false infeasible is rescued (returns optimal). Without
560        // this, a false INFEASIBLE bypassed the escalation entirely and became a
561        // fatal training error. Mirrors the cold-solve escalation on the CLP path.
562        let is_infeasible = model_status == ffi::HIGHS_MODEL_STATUS_INFEASIBLE;
563        let initial_retryable = is_unbounded
564            || is_infeasible
565            || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
566            || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
567            || solve_time > 15.0;
568        if !initial_retryable
569            && let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time)
570        {
571            self.restore_iteration_limits();
572            self.stats.failure_count += 1;
573            return Err(terminal_err);
574        }
575
576        // Delegate to the retry escalation method (restores limits internally).
577        match self.retry_escalation(is_unbounded) {
578            Ok(outcome) => {
579                self.stats.retry_count += outcome.attempts;
580                self.stats.success_count += 1;
581                self.stats.total_iterations += outcome.iterations;
582                self.stats.total_solve_time_seconds += outcome.solve_time;
583                self.stats.retry_level_histogram[outcome.level as usize] += 1;
584                Ok(self.extract_solution_view(outcome.solve_time))
585            }
586            Err((attempts, err)) => {
587                self.stats.retry_count += attempts;
588                self.stats.failure_count += 1;
589                Err(err)
590            }
591        }
592    }
593}
594
595impl Drop for HighsSolver {
596    fn drop(&mut self) {
597        // SAFETY: valid HiGHS pointer from construction, called once per instance.
598        unsafe { ffi::cobre_highs_destroy(self.handle) };
599    }
600}
601
602/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
603///
604/// This is a free function — no solver instance is required.
605///
606/// # Example
607///
608/// ```rust
609/// # #[cfg(feature = "highs")]
610/// # {
611/// let v = cobre_solver::highs_version();
612/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
613/// # }
614/// ```
615#[must_use]
616pub fn highs_version() -> String {
617    // SAFETY: These are pure query functions with no arguments. The HiGHS C API
618    // documents them as safe to call without any prior initialisation; they read
619    // only compile-time constants embedded in the library.
620    let major = unsafe { crate::ffi::cobre_highs_version_major() };
621    let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
622    let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
623    format!("{major}.{minor}.{patch}")
624}
625
626/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
627///
628/// Gated behind the `test-support` feature. The raw handle is intentionally not
629/// part of the public API — callers use these methods to configure time/iteration
630/// limits before a solve without going through the safe wrapper.
631#[cfg(feature = "test-support")]
632impl HighsSolver {
633    /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
634    ///
635    /// # Safety
636    ///
637    /// The returned pointer is valid for the lifetime of `self`. The caller must
638    /// not store the pointer beyond that lifetime, must not call
639    /// `cobre_highs_destroy` on it, and must not alias it across threads.
640    #[must_use]
641    pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
642        self.handle
643    }
644
645    /// Invoke `apply_retry_level_options` for a given level.
646    ///
647    /// Thin test-support wrapper that exposes the private retry-level applier
648    /// so integration tests can verify option composition without driving a
649    /// full solve through the retry ladder.
650    ///
651    /// Only levels 0-4 are routed through this method; levels 5-11 delegate to
652    /// `apply_extended_retry_options_for_test`. Call the appropriate method
653    /// based on the level you want to test.
654    pub fn apply_retry_level_options_for_test(&mut self, level: u32) {
655        self.apply_retry_level_options(level);
656    }
657
658    /// Invoke `apply_extended_retry_options` for a given level (5-11).
659    ///
660    /// Thin test-support wrapper that exposes the private extended retry-level
661    /// applier so integration tests can verify option composition without
662    /// driving a full solve.
663    pub fn apply_extended_retry_options_for_test(&mut self, level: u32) {
664        self.apply_extended_retry_options(level);
665    }
666
667    /// Invoke `restore_default_settings` then `apply_profile_tolerances` in sequence.
668    ///
669    /// Mirrors the finalization path in `retry_escalation` so integration tests
670    /// can verify that profile tolerances survive a defaults-restore without
671    /// driving a full retry through a failing LP.
672    pub fn restore_defaults_then_apply_profile_for_test(&mut self) {
673        self.restore_default_settings();
674        self.apply_profile_tolerances();
675    }
676
677    /// Read a double-valued `HiGHS` option by name.
678    ///
679    /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
680    /// on success.
681    #[must_use]
682    pub fn get_double_option(&self, option: &std::ffi::CStr) -> Option<f64> {
683        let mut out = 0.0_f64;
684        // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
685        // null-terminated C string borrowed for the duration of the call;
686        // `out` is stack-allocated and written by HiGHS on success.
687        let status = unsafe {
688            ffi::cobre_highs_get_double_option(self.handle, option.as_ptr(), &raw mut out)
689        };
690        if status == ffi::HIGHS_STATUS_ERROR {
691            None
692        } else {
693            Some(out)
694        }
695    }
696
697    /// Read an integer-valued `HiGHS` option by name.
698    ///
699    /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
700    /// on success.
701    #[must_use]
702    pub fn get_int_option(&self, option: &std::ffi::CStr) -> Option<i32> {
703        let mut out = 0_i32;
704        // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
705        // null-terminated C string borrowed for the duration of the call;
706        // `out` is stack-allocated and written by HiGHS on success.
707        let status =
708            unsafe { ffi::cobre_highs_get_int_option(self.handle, option.as_ptr(), &raw mut out) };
709        if status == ffi::HIGHS_STATUS_ERROR {
710            None
711        } else {
712            Some(out)
713        }
714    }
715}