Skip to main content

cobre_solver/
highs.rs

1//! `HiGHS` LP solver backend implementing [`SolverInterface`].
2//!
3//! This module provides [`HighsSolver`], which wraps the `HiGHS` C API through
4//! the FFI layer in `ffi` and implements the full [`SolverInterface`]
5//! contract for iterative LP solving in power system optimization.
6//!
7//! # Thread Safety
8//!
9//! [`HighsSolver`] is `Send` but not `Sync`. The underlying `HiGHS` handle is
10//! exclusively owned; transferring ownership to a worker thread is safe.
11//! Concurrent access from multiple threads is not permitted (`HiGHS`
12//! Implementation SS6.3).
13//!
14//! # Configuration
15//!
16//! The constructor applies performance-tuned defaults (`HiGHS` Implementation
17//! SS4.1): dual simplex, no presolve, no parallelism, suppressed output, and
18//! tight feasibility tolerances. These defaults are optimised for repeated
19//! solves of small-to-medium LPs. Per-run parameters (time limit, iteration
20//! limit) are not set here -- those are applied by the caller before each solve.
21
22use std::ffi::CStr;
23use std::os::raw::c_void;
24use std::time::Instant;
25
26use crate::{
27    SolverInterface, ffi,
28    types::{RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate},
29};
30
31// ─── Default HiGHS configuration ─────────────────────────────────────────────
32//
33// The eight performance-tuned options applied at construction and restored after
34// each retry escalation. Keeping them in a single array eliminates per-option
35// error branches that are structurally impossible to trigger in tests (HiGHS
36// never rejects valid static option names).
37
38/// A typed `HiGHS` option value for the configuration table.
39enum OptionValue {
40    /// String option (`cobre_highs_set_string_option`).
41    Str(&'static CStr),
42    /// Integer option (`cobre_highs_set_int_option`).
43    Int(i32),
44    /// Boolean option (`cobre_highs_set_bool_option`).
45    Bool(i32),
46    /// Double option (`cobre_highs_set_double_option`).
47    Double(f64),
48}
49
50/// A named `HiGHS` option with its default value.
51struct DefaultOption {
52    name: &'static CStr,
53    value: OptionValue,
54}
55
56impl DefaultOption {
57    /// Applies this option to a `HiGHS` handle. Returns the `HiGHS` status code.
58    ///
59    /// # Safety
60    ///
61    /// `handle` must be a valid, non-null pointer from `cobre_highs_create()`.
62    unsafe fn apply(&self, handle: *mut c_void) -> i32 {
63        unsafe {
64            match &self.value {
65                OptionValue::Str(val) => {
66                    ffi::cobre_highs_set_string_option(handle, self.name.as_ptr(), val.as_ptr())
67                }
68                OptionValue::Int(val) => {
69                    ffi::cobre_highs_set_int_option(handle, self.name.as_ptr(), *val)
70                }
71                OptionValue::Bool(val) => {
72                    ffi::cobre_highs_set_bool_option(handle, self.name.as_ptr(), *val)
73                }
74                OptionValue::Double(val) => {
75                    ffi::cobre_highs_set_double_option(handle, self.name.as_ptr(), *val)
76                }
77            }
78        }
79    }
80}
81
82/// Performance-tuned default options (`HiGHS` Implementation SS4.1).
83///
84/// These thirteen options are applied at construction and restored after each
85/// retry escalation. The values are tuned for master LPs dominated by many
86/// slack rows that are warm-started across consecutive solves.
87///
88/// `simplex_scale_strategy` is set to 0 (off) because the calling algorithm's
89/// prescaler already normalizes matrix entries toward 1.0; the solver's
90/// internal equilibration scaling is redundant and can distort cost ordering
91/// for large-RHS rows. Retry escalation levels 5+ override this to more
92/// aggressive strategies as a fallback for hard problems.
93///
94/// The last four entries diverge from `HiGHS` defaults to suit warm-started
95/// solves on master LPs with tens of thousands of mostly-slack rows: Devex
96/// pricing avoids per-pivot edge-weight maintenance scaling with row count;
97/// disabling cost perturbation removes wasted cleanup pivots when the basis
98/// is already near-optimal (Bland's rule guards against cycling, and retry
99/// level 0 restores perturbation as a fallback); skipping the initial
100/// condition check eliminates O(m)–O(m²) work whose guarantees are already
101/// provided by the caller's slot-tracked basis reconstruction; row-wise
102/// PRICE wins on hyper-sparse basis-inverse rows; loosening the
103/// rebuild-refactor tolerance skips conservative refactorizations that
104/// aren't earning their keep in this numerically benign regime.
105fn default_options() -> [DefaultOption; 13] {
106    [
107        DefaultOption {
108            name: c"solver",
109            value: OptionValue::Str(c"simplex"),
110        },
111        DefaultOption {
112            name: c"simplex_strategy",
113            value: OptionValue::Int(1), // Dual simplex
114        },
115        DefaultOption {
116            name: c"simplex_scale_strategy",
117            value: OptionValue::Int(0), // Off (prescaler handles scaling)
118        },
119        DefaultOption {
120            name: c"presolve",
121            value: OptionValue::Str(c"off"),
122        },
123        DefaultOption {
124            name: c"parallel",
125            value: OptionValue::Str(c"off"),
126        },
127        DefaultOption {
128            name: c"output_flag",
129            value: OptionValue::Bool(0),
130        },
131        DefaultOption {
132            name: c"primal_feasibility_tolerance",
133            value: OptionValue::Double(1e-7),
134        },
135        DefaultOption {
136            name: c"dual_feasibility_tolerance",
137            value: OptionValue::Double(1e-7),
138        },
139        DefaultOption {
140            name: c"simplex_dual_edge_weight_strategy",
141            value: OptionValue::Int(1), // Devex
142        },
143        DefaultOption {
144            name: c"dual_simplex_cost_perturbation_multiplier",
145            value: OptionValue::Double(0.0), // Off (warm-start regime)
146        },
147        DefaultOption {
148            name: c"simplex_initial_condition_check",
149            value: OptionValue::Bool(0), // Off (caller manages basis quality)
150        },
151        DefaultOption {
152            name: c"simplex_price_strategy",
153            value: OptionValue::Int(1), // Row (hyper-sparse master LPs)
154        },
155        DefaultOption {
156            name: c"rebuild_refactor_solution_error_tolerance",
157            value: OptionValue::Double(1e-6), // Loosened from HiGHS default 1e-8
158        },
159    ]
160}
161
162/// `HiGHS` LP solver instance implementing [`SolverInterface`].
163///
164/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
165/// extraction, scratch i32 index conversion, and statistics accumulation.
166///
167/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
168/// when the instance is dropped.
169///
170/// # Example
171///
172/// ```rust
173/// use cobre_solver::{HighsSolver, SolverInterface};
174///
175/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
176/// assert_eq!(solver.name(), "HiGHS");
177/// ```
178pub struct HighsSolver {
179    /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
180    handle: *mut c_void,
181    /// Pre-allocated buffer for primal column values extracted after each solve.
182    /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
183    col_value: Vec<f64>,
184    /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
185    /// Resized in `load_model`.
186    col_dual: Vec<f64>,
187    /// Pre-allocated buffer for row primal values (constraint activity).
188    /// Resized in `load_model`.
189    row_value: Vec<f64>,
190    /// Pre-allocated buffer for row dual multipliers (shadow prices).
191    /// Resized in `load_model`.
192    row_dual: Vec<f64>,
193    /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
194    /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
195    /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
196    scratch_i32: Vec<i32>,
197    /// Pre-allocated i32 buffer for column basis status codes.
198    /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
199    /// Resized in `load_model` to `num_cols`; never shrunk.
200    basis_col_i32: Vec<i32>,
201    /// Pre-allocated i32 buffer for row basis status codes.
202    /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
203    /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
204    basis_row_i32: Vec<i32>,
205    /// Scratch buffer for dual-ray extraction in `interpret_terminal_status` (dual).
206    /// Grown lazily to `num_rows` via `resize`; contents are discarded after classification.
207    /// Retained across calls so repeated non-optimal solves do not re-allocate.
208    terminal_status_dual_scratch: Vec<f64>,
209    /// Scratch buffer for primal-ray extraction in `interpret_terminal_status` (primal).
210    /// Grown lazily to `num_cols` via `resize`; contents are discarded after classification.
211    /// Retained across calls so repeated non-optimal solves do not re-allocate.
212    terminal_status_primal_scratch: Vec<f64>,
213    /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
214    num_cols: usize,
215    /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
216    num_rows: usize,
217    /// Whether a model is currently loaded. Set to `true` in `load_model`,
218    /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
219    has_model: bool,
220    /// Accumulated solver statistics. Counters grow monotonically from zero;
221    /// not reset by `reset()`.
222    stats: SolverStatistics,
223}
224
225// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
226// handle is not thread-safe for concurrent access, but exclusive ownership is
227// maintained at all times -- exactly one `HighsSolver` instance owns each
228// handle and no shared references to the handle exist. Transferring the
229// `HighsSolver` to another thread (via `Send`) is safe because there is no
230// concurrent access; the new thread has exclusive ownership. `Sync` is
231// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
232unsafe impl Send for HighsSolver {}
233
234/// Outcome of a successful retry escalation in [`HighsSolver::retry_escalation`].
235///
236/// Contains the accumulated attempt count and the solve time / iteration
237/// count from the successful retry level.
238struct RetryOutcome {
239    attempts: u64,
240    solve_time: f64,
241    iterations: u64,
242    /// The retry level (0..11) at which the solve succeeded.
243    level: u32,
244}
245
246impl HighsSolver {
247    /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
248    ///
249    /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
250    /// the thirteen default options defined in `HiGHS` Implementation SS4.1:
251    ///
252    /// | Option                                      | Value       | Type   |
253    /// |---------------------------------------------|-------------|--------|
254    /// | `solver`                                    | `"simplex"` | string |
255    /// | `simplex_strategy`                          | `1`         | int    |
256    /// | `simplex_scale_strategy`                    | `0`         | int    |
257    /// | `presolve`                                  | `"off"`     | string |
258    /// | `parallel`                                  | `"off"`     | string |
259    /// | `output_flag`                               | `0`         | bool   |
260    /// | `primal_feasibility_tolerance`              | `1e-7`      | double |
261    /// | `dual_feasibility_tolerance`                | `1e-7`      | double |
262    /// | `simplex_dual_edge_weight_strategy`         | `1`         | int    |
263    /// | `dual_simplex_cost_perturbation_multiplier` | `0.0`       | double |
264    /// | `simplex_initial_condition_check`           | `0`         | bool   |
265    /// | `simplex_price_strategy`                    | `1`         | int    |
266    /// | `rebuild_refactor_solution_error_tolerance` | `1e-6`      | double |
267    ///
268    /// # Errors
269    ///
270    /// Returns `Err(SolverError::InternalError { .. })` if:
271    /// - `cobre_highs_create()` returns a null pointer.
272    /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
273    ///
274    /// In both failure cases the `HiGHS` handle is destroyed before returning to
275    /// prevent a resource leak.
276    pub fn new() -> Result<Self, SolverError> {
277        // SAFETY: `cobre_highs_create` is a C function with no preconditions.
278        // It allocates and returns a new `HiGHS` instance, or null on allocation
279        // failure. The returned pointer is opaque and must be passed back to
280        // `HiGHS` API functions.
281        let handle = unsafe { ffi::cobre_highs_create() };
282
283        if handle.is_null() {
284            return Err(SolverError::InternalError {
285                message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
286                error_code: None,
287            });
288        }
289
290        // Apply performance-tuned configuration. On any failure, destroy the
291        // handle before returning to prevent a resource leak.
292        if let Err(e) = Self::apply_default_config(handle) {
293            // SAFETY: `handle` is a valid, non-null pointer obtained from
294            // `cobre_highs_create()` in this same function. It has not been
295            // passed to `cobre_highs_destroy()` yet. After this call, `handle`
296            // must not be used again -- this function returns immediately with Err.
297            unsafe { ffi::cobre_highs_destroy(handle) };
298            return Err(e);
299        }
300
301        Ok(Self {
302            handle,
303            col_value: Vec::new(),
304            col_dual: Vec::new(),
305            row_value: Vec::new(),
306            row_dual: Vec::new(),
307            scratch_i32: Vec::new(),
308            basis_col_i32: Vec::new(),
309            basis_row_i32: Vec::new(),
310            terminal_status_dual_scratch: Vec::new(),
311            terminal_status_primal_scratch: Vec::new(),
312            num_cols: 0,
313            num_rows: 0,
314            has_model: false,
315            stats: SolverStatistics {
316                retry_level_histogram: vec![0u64; 12],
317                ..SolverStatistics::default()
318            },
319        })
320    }
321
322    /// Applies the eight performance-tuned `HiGHS` configuration options.
323    ///
324    /// Called once during construction. Returns `Ok(())` if all options are set
325    /// successfully, or `Err(SolverError::InternalError)` with the failing
326    /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
327    fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
328        for opt in &default_options() {
329            // SAFETY: `handle` is a valid, non-null HiGHS pointer.
330            let status = unsafe { opt.apply(handle) };
331            if status == ffi::HIGHS_STATUS_ERROR {
332                return Err(SolverError::InternalError {
333                    message: format!(
334                        "HiGHS configuration failed: {}",
335                        opt.name.to_str().unwrap_or("?")
336                    ),
337                    error_code: Some(status),
338                });
339            }
340        }
341        Ok(())
342    }
343
344    /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
345    /// a [`SolutionView`] borrowing directly from those buffers.
346    ///
347    /// The returned view borrows solver-internal buffers and is valid until the next
348    /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
349    /// canonical sign convention (per Solver Abstraction SS8).
350    fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
351        // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
352        let status = unsafe {
353            ffi::cobre_highs_get_solution(
354                self.handle,
355                self.col_value.as_mut_ptr(),
356                self.col_dual.as_mut_ptr(),
357                self.row_value.as_mut_ptr(),
358                self.row_dual.as_mut_ptr(),
359            )
360        };
361        // HiGHS documentation guarantees `cobre_highs_get_solution` returns
362        // non-ERROR status after `OPTIMAL` model status; this is a
363        // debug-build-only invariant check.
364        debug_assert_ne!(
365            status,
366            ffi::HIGHS_STATUS_ERROR,
367            "cobre_highs_get_solution failed after optimal solve; HiGHS invariant violation"
368        );
369
370        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
371        let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
372
373        // SAFETY: iteration count is non-negative so cast is safe.
374        #[allow(clippy::cast_sign_loss)]
375        let iterations =
376            unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
377
378        SolutionView {
379            objective,
380            primal: &self.col_value[..self.num_cols],
381            dual: &self.row_dual[..self.num_rows],
382            reduced_costs: &self.col_dual[..self.num_cols],
383            iterations,
384            solve_time_seconds,
385        }
386    }
387
388    /// Restores default options after retry escalation.
389    ///
390    /// Status codes are checked via `debug_assert!` to catch programming
391    /// errors during development (e.g., invalid option name). In release
392    /// builds, failures are silently ignored since we are already on the
393    /// recovery path.
394    fn restore_default_settings(&mut self) {
395        for opt in &default_options() {
396            // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
397            let status = unsafe { opt.apply(self.handle) };
398            debug_assert_eq!(
399                status,
400                ffi::HIGHS_STATUS_OK,
401                "restore_default_settings: option {:?} failed with status {status}",
402                opt.name,
403            );
404        }
405    }
406
407    /// Runs the solver once and returns the raw `HiGHS` model status.
408    fn run_once(&mut self) -> i32 {
409        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
410        let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
411        if run_status == ffi::HIGHS_STATUS_ERROR {
412            return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
413        }
414        // SAFETY: same.
415        unsafe { ffi::cobre_highs_get_model_status(self.handle) }
416    }
417
418    /// Sets per-solve iteration limits before a `run_once()` call.
419    ///
420    /// Simplex gets `max(100_000, 50 × num_cols)` and IPM gets 10,000.
421    /// These prevent degenerate cycling without affecting normal convergence.
422    ///
423    /// **Note on `time_limit`**: `HiGHS` tracks elapsed time cumulatively from
424    /// instance creation, not per-`run()` call — neither `clear_solver()` nor
425    /// option changes reset the internal timer. This makes `time_limit`
426    /// unusable for the scenario-loop pattern (thousands of solves per
427    /// instance). Wall-clock measurement via `Instant` is used instead for
428    /// time-based budget management.
429    fn set_iteration_limits(&mut self) {
430        let simplex_iter_limit = self.num_cols.saturating_mul(50).max(100_000);
431        // SAFETY: handle is valid non-null HiGHS pointer; option names are
432        // static C strings with no retained pointers.
433        unsafe {
434            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
435            ffi::cobre_highs_set_int_option(
436                self.handle,
437                c"simplex_iteration_limit".as_ptr(),
438                simplex_iter_limit as i32,
439            );
440            ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), 10_000);
441        }
442    }
443
444    /// Restores iteration limits to their unconstrained defaults.
445    ///
446    /// Called after `retry_escalation` completes (regardless of outcome).
447    fn restore_iteration_limits(&mut self) {
448        // SAFETY: handle is valid non-null HiGHS pointer.
449        unsafe {
450            ffi::cobre_highs_set_int_option(
451                self.handle,
452                c"simplex_iteration_limit".as_ptr(),
453                i32::MAX,
454            );
455            ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), i32::MAX);
456        }
457    }
458
459    /// Interprets a non-optimal status as a terminal `SolverError`.
460    ///
461    /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
462    /// or `Some(error)` for terminal statuses.
463    fn interpret_terminal_status(
464        &mut self,
465        status: i32,
466        solve_time_seconds: f64,
467    ) -> Option<SolverError> {
468        match status {
469            ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
470                // Caller should have handled optimal before reaching here.
471                None
472            }
473            ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
474            ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
475                // Probe for a dual ray to classify as Infeasible, then a primal
476                // ray to classify as Unbounded. The ray values are not stored in
477                // the error -- only the classification matters.
478                //
479                // `num_rows` and `num_cols` are up-to-date because `load_model`
480                // and `add_rows` always update them before any solve that could
481                // reach this branch. The `resize` below matches the exact count
482                // that HiGHS writes into the buffer.
483                let mut has_dual_ray: i32 = 0;
484                self.terminal_status_dual_scratch.resize(self.num_rows, 0.0);
485                // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
486                // `terminal_status_dual_scratch` has been resized to at least
487                // `self.num_rows` elements; HiGHS writes exactly `num_rows` values.
488                let dual_status = unsafe {
489                    ffi::cobre_highs_get_dual_ray(
490                        self.handle,
491                        &raw mut has_dual_ray,
492                        self.terminal_status_dual_scratch.as_mut_ptr(),
493                    )
494                };
495                if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
496                    return Some(SolverError::Infeasible);
497                }
498                let mut has_primal_ray: i32 = 0;
499                self.terminal_status_primal_scratch
500                    .resize(self.num_cols, 0.0);
501                // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
502                // `terminal_status_primal_scratch` has been resized to at least
503                // `self.num_cols` elements; HiGHS writes exactly `num_cols` values.
504                let primal_status = unsafe {
505                    ffi::cobre_highs_get_primal_ray(
506                        self.handle,
507                        &raw mut has_primal_ray,
508                        self.terminal_status_primal_scratch.as_mut_ptr(),
509                    )
510                };
511                if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
512                    return Some(SolverError::Unbounded);
513                }
514                Some(SolverError::Infeasible)
515            }
516            ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
517            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
518                elapsed_seconds: solve_time_seconds,
519            }),
520            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
521                // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
522                #[allow(clippy::cast_sign_loss)]
523                let iterations =
524                    unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
525                Some(SolverError::IterationLimit { iterations })
526            }
527            ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
528                // Signal to the caller that retry should continue.
529                None
530            }
531            other => Some(SolverError::InternalError {
532                message: format!("HiGHS returned unexpected model status {other}"),
533                error_code: Some(other),
534            }),
535        }
536    }
537
538    /// Converts `usize` indices to `i32` in the internal scratch buffer.
539    ///
540    /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
541    fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
542        if source.len() > self.scratch_i32.len() {
543            self.scratch_i32.resize(source.len(), 0);
544        }
545        for (i, &v) in source.iter().enumerate() {
546            debug_assert!(
547                i32::try_from(v).is_ok(),
548                "usize index {v} overflows i32::MAX at position {i}"
549            );
550            // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
551            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
552            {
553                self.scratch_i32[i] = v as i32;
554            }
555        }
556        &self.scratch_i32[..source.len()]
557    }
558
559    /// Run the 12-level retry escalation when the initial solve fails.
560    ///
561    /// Returns `Ok(RetryOutcome)` when a retry level finds optimal, or
562    /// `Err((attempts, SolverError))` when all levels are exhausted or a
563    /// terminal error is encountered. The caller is responsible for
564    /// updating `self.stats` based on the outcome.
565    ///
566    /// Settings are always restored to defaults before returning (regardless
567    /// of outcome).
568    fn retry_escalation(&mut self, is_unbounded: bool) -> Result<RetryOutcome, (u64, SolverError)> {
569        // 12-level retry escalation (HiGHS Implementation SS3). Organised into
570        // two phases:
571        //
572        // Phase 1 (levels 0-4): Core cumulative sequence. Each level adds one
573        //   option on top of the previous state. This proven sequence resolves
574        //   the vast majority of retry-recoverable failures.
575        //   L0: cold restart
576        //   L1: + presolve
577        //   L2: + dual simplex
578        //   L3: + relaxed tolerances 1e-6
579        //   L4: + IPM
580        //
581        // Phase 2 (levels 5-11): Extended strategies. Each level starts from
582        //   a clean default state with presolve enabled and a time cap, then
583        //   applies a specific combination of scaling, tolerances, and solver
584        //   type. These address LPs with extreme coefficient ranges that the
585        //   core sequence cannot resolve.
586        //
587        // Wall-clock per-level budgets: 15s (Phase 1), 30s (Phase 2), 60s
588        // (Phase 2 extended). Overall 120s wall-clock budget caps the total.
589        //
590        // HiGHS `time_limit` is NOT used because HiGHS tracks elapsed time
591        // cumulatively from instance creation — neither `clear_solver()` nor
592        // option changes reset the internal timer. Iteration limits provide
593        // the primary per-attempt safeguard; wall-clock budgets provide the
594        // secondary time-based guard.
595        let phase1_wall_budget = 15.0_f64;
596        let phase2_wall_budget = 30.0_f64;
597        let overall_budget = 120.0_f64;
598        let num_retry_levels = 12_u32;
599
600        let retry_start = Instant::now();
601        let mut retry_attempts: u64 = 0;
602        let mut terminal_err: Option<SolverError> = None;
603        let mut found_optimal = false;
604        let mut optimal_time = 0.0_f64;
605        let mut optimal_iterations: u64 = 0;
606        let mut optimal_level = 0_u32;
607
608        for level in 0..num_retry_levels {
609            // Check overall wall-clock budget before starting a new level.
610            if retry_start.elapsed().as_secs_f64() >= overall_budget {
611                break;
612            }
613
614            self.apply_retry_level_options(level);
615
616            retry_attempts += 1;
617
618            let t_retry = Instant::now();
619            let retry_status = self.run_once();
620            let retry_time = t_retry.elapsed().as_secs_f64();
621
622            if retry_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
623                // Capture stats before establishing the borrow.
624                // SAFETY: handle is valid non-null HiGHS pointer.
625                #[allow(clippy::cast_sign_loss)]
626                let iters =
627                    unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
628                found_optimal = true;
629                optimal_time = retry_time;
630                optimal_iterations = iters;
631                optimal_level = level;
632                break;
633            }
634
635            // UNBOUNDED and ITERATION_LIMIT during retry continue to the next
636            // level: UNBOUNDED may be spurious (presolve resolves it);
637            // ITERATION_LIMIT means this strategy is cycling but another may
638            // converge. Wall-clock budget exceeded also continues (strategy
639            // too slow). Other terminal statuses (INFEASIBLE) stop immediately.
640            let level_budget = if level <= 4 {
641                phase1_wall_budget
642            } else {
643                phase2_wall_budget
644            };
645            let budget_exceeded = retry_time > level_budget;
646            let retryable = retry_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED
647                || retry_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
648                || budget_exceeded;
649            if !retryable {
650                if let Some(e) = self.interpret_terminal_status(retry_status, retry_time) {
651                    terminal_err = Some(e);
652                    break;
653                }
654            }
655            // Still SOLVE_ERROR, UNKNOWN, UNBOUNDED, ITERATION_LIMIT, or
656            // wall-clock exceeded -- continue to next level.
657        }
658
659        // Restore default settings and safeguard limits unconditionally.
660        // `restore_default_settings()` covers the 8 defaults. Retry-only
661        // options and safeguard limits need explicit reset.
662        self.restore_default_settings();
663        self.restore_iteration_limits();
664        unsafe {
665            ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), 0);
666            ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), 0);
667        }
668
669        if found_optimal {
670            return Ok(RetryOutcome {
671                attempts: retry_attempts,
672                solve_time: optimal_time,
673                iterations: optimal_iterations,
674                level: optimal_level,
675            });
676        }
677
678        Err((
679            retry_attempts,
680            terminal_err.unwrap_or_else(|| {
681                // All 12 retry levels exhausted or overall budget exceeded.
682                if is_unbounded {
683                    SolverError::Unbounded
684                } else {
685                    SolverError::NumericalDifficulty {
686                        message:
687                            "HiGHS failed to reach optimality after all retry escalation levels"
688                                .to_string(),
689                    }
690                }
691            }),
692        ))
693    }
694
695    /// Apply `HiGHS` options for a specific retry escalation level.
696    ///
697    /// Phase 1 (levels 0-4) is cumulative: each level adds options on top of
698    /// the previous state. Both phases apply `time_limit` and iteration limits
699    /// as safeguards against hanging on hard LPs.
700    ///
701    /// Phase 2 (levels 5-11) starts fresh each time with its own time limit.
702    ///
703    /// # Safety (internal)
704    ///
705    /// All FFI calls use `self.handle` which is a valid non-null `HiGHS` pointer.
706    /// Option names and values are static C strings with no retained pointers.
707    fn apply_retry_level_options(&mut self, level: u32) {
708        match level {
709            // -- Phase 1: Core cumulative sequence (levels 0-4) ---------------
710            //
711            // Level 0: cold restart (clear solver state) and re-enable the
712            // dual-simplex cost perturbation. The default configuration runs
713            // with perturbation off (see `DUAL_SIMPLEX_COST_PERTURBATION_MULTIPLIER`)
714            // for warm-start performance, which can stall on degenerate vertices;
715            // restoring the `HiGHS` default of `1.0` is the cheapest first-line
716            // intervention against cycling. Persists through levels 1-4 because
717            // Phase 1 is cumulative.
718            0 => {
719                unsafe {
720                    ffi::cobre_highs_clear_solver(self.handle);
721                    ffi::cobre_highs_set_double_option(
722                        self.handle,
723                        c"dual_simplex_cost_perturbation_multiplier".as_ptr(),
724                        1.0,
725                    );
726                }
727                self.set_iteration_limits();
728            }
729            // Level 1: + presolve.
730            1 => unsafe {
731                ffi::cobre_highs_set_string_option(
732                    self.handle,
733                    c"presolve".as_ptr(),
734                    c"on".as_ptr(),
735                );
736            },
737            // Level 2: + dual simplex.
738            // Cumulative: presolve + dual simplex.
739            2 => unsafe {
740                ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
741            },
742            // Level 3: + relaxed tolerances 1e-6.
743            // Cumulative: presolve + dual simplex + relaxed tolerances.
744            3 => unsafe {
745                ffi::cobre_highs_set_double_option(
746                    self.handle,
747                    c"primal_feasibility_tolerance".as_ptr(),
748                    1e-6,
749                );
750                ffi::cobre_highs_set_double_option(
751                    self.handle,
752                    c"dual_feasibility_tolerance".as_ptr(),
753                    1e-6,
754                );
755            },
756            // Level 4: + IPM.
757            // Cumulative: presolve + relaxed tolerances + IPM.
758            4 => unsafe {
759                ffi::cobre_highs_set_string_option(
760                    self.handle,
761                    c"solver".as_ptr(),
762                    c"ipm".as_ptr(),
763                );
764            },
765
766            // -- Phase 2: Extended strategies (levels 5-11) -------------------
767            // Each level starts from a clean default state with presolve
768            // and iteration limits, then applies specific options.
769            _ => self.apply_extended_retry_options(level),
770        }
771    }
772
773    /// Apply Phase 2 extended retry strategy options for levels 5-11.
774    ///
775    /// Each level starts from restored defaults with presolve and iteration
776    /// limits, then applies level-specific scaling, tolerance, and solver
777    /// options. Wall-clock budgets are managed by the caller.
778    fn apply_extended_retry_options(&mut self, level: u32) {
779        self.restore_default_settings();
780        self.set_iteration_limits();
781        // SAFETY: handle is valid non-null HiGHS pointer; option names/values
782        // are static C strings; no retained pointers after call.
783        unsafe {
784            ffi::cobre_highs_set_string_option(self.handle, c"presolve".as_ptr(), c"on".as_ptr());
785        }
786        match level {
787            5 => unsafe {
788                ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 3);
789            },
790            6 => unsafe {
791                ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
792                ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 4);
793            },
794            7 => unsafe {
795                ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 3);
796                ffi::cobre_highs_set_double_option(
797                    self.handle,
798                    c"primal_feasibility_tolerance".as_ptr(),
799                    1e-6,
800                );
801                ffi::cobre_highs_set_double_option(
802                    self.handle,
803                    c"dual_feasibility_tolerance".as_ptr(),
804                    1e-6,
805                );
806            },
807            8 => unsafe {
808                ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
809            },
810            9 => unsafe {
811                ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
812                ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
813                ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
814            },
815            10 => unsafe {
816                ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -13);
817                ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -8);
818                ffi::cobre_highs_set_double_option(
819                    self.handle,
820                    c"primal_feasibility_tolerance".as_ptr(),
821                    1e-6,
822                );
823                ffi::cobre_highs_set_double_option(
824                    self.handle,
825                    c"dual_feasibility_tolerance".as_ptr(),
826                    1e-6,
827                );
828            },
829            11 => unsafe {
830                ffi::cobre_highs_set_string_option(
831                    self.handle,
832                    c"solver".as_ptr(),
833                    c"ipm".as_ptr(),
834                );
835                ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
836                ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
837                ffi::cobre_highs_set_double_option(
838                    self.handle,
839                    c"primal_feasibility_tolerance".as_ptr(),
840                    1e-6,
841                );
842                ffi::cobre_highs_set_double_option(
843                    self.handle,
844                    c"dual_feasibility_tolerance".as_ptr(),
845                    1e-6,
846                );
847            },
848            _ => unreachable!(),
849        }
850    }
851
852    /// Internal helper: run the simplex and update stats.
853    ///
854    /// Core simplex execution, called after (for warm-start) the basis has been
855    /// installed. `HiGHS` retains its internal simplex basis across consecutive
856    /// `solve_inner` calls on the same LP shape, which is the primary warm-start
857    /// mechanism for the backward pass. No `Highs_clearSolver` call is issued —
858    /// that behavior was removed in commit `25f1351` to recover a 4.7× perf regression.
859    fn solve_inner(&mut self) -> Result<SolutionView<'_>, SolverError> {
860        // Safeguard: apply iteration limits before the initial attempt.
861        // Time limits are NOT set here — HiGHS tracks time cumulatively from
862        // instance creation, so a per-solve time_limit would fire spuriously
863        // on long-running solver instances. Instead, wall-clock time is checked
864        // after run_once() to detect stuck solves.
865        self.set_iteration_limits();
866
867        let t0 = Instant::now();
868        let model_status = self.run_once();
869        let solve_time = t0.elapsed().as_secs_f64();
870
871        self.stats.solve_count += 1;
872
873        if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
874            // Read iteration count from FFI BEFORE establishing the shared borrow
875            // via extract_solution_view, so stats can be updated without violating
876            // the aliasing rules.
877            // SAFETY: handle is valid non-null HiGHS pointer.
878            #[allow(clippy::cast_sign_loss)]
879            let iterations =
880                unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
881            self.stats.success_count += 1;
882            self.stats.first_try_successes += 1;
883            self.stats.total_iterations += iterations;
884            self.stats.total_solve_time_seconds += solve_time;
885            self.restore_iteration_limits();
886            return Ok(self.extract_solution_view(solve_time));
887        }
888
889        // Check for a definitive terminal status (not a retry-able error).
890        // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
891        // on numerically difficult LPs with wide coefficient ranges. The retry
892        // escalation (especially presolve in the core sequence) often resolves these.
893        // ITERATION_LIMIT from the initial attempt is retryable — the retry
894        // sequence uses different strategies that may converge faster.
895        // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
896        // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
897        // in edge cases. Retry level 0 (cold restart) recovers from this.
898        // Wall-clock > 15s is also retryable — detects stuck initial solves.
899        let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
900        let initial_retryable = is_unbounded
901            || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
902            || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
903            || solve_time > 15.0;
904        if !initial_retryable {
905            if let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time) {
906                self.restore_iteration_limits();
907                self.stats.failure_count += 1;
908                return Err(terminal_err);
909            }
910        }
911
912        // Delegate to the retry escalation method (restores limits internally).
913        match self.retry_escalation(is_unbounded) {
914            Ok(outcome) => {
915                self.stats.retry_count += outcome.attempts;
916                self.stats.success_count += 1;
917                self.stats.total_iterations += outcome.iterations;
918                self.stats.total_solve_time_seconds += outcome.solve_time;
919                self.stats.retry_level_histogram[outcome.level as usize] += 1;
920                Ok(self.extract_solution_view(outcome.solve_time))
921            }
922            Err((attempts, err)) => {
923                self.stats.retry_count += attempts;
924                self.stats.failure_count += 1;
925                Err(err)
926            }
927        }
928    }
929}
930
931impl Drop for HighsSolver {
932    fn drop(&mut self) {
933        // SAFETY: valid HiGHS pointer from construction, called once per instance.
934        unsafe { ffi::cobre_highs_destroy(self.handle) };
935    }
936}
937
938/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
939///
940/// This is a free function — no solver instance is required.
941///
942/// # Example
943///
944/// ```rust
945/// # #[cfg(feature = "highs")]
946/// # {
947/// let v = cobre_solver::highs_version();
948/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
949/// # }
950/// ```
951#[must_use]
952pub fn highs_version() -> String {
953    // SAFETY: These are pure query functions with no arguments. The HiGHS C API
954    // documents them as safe to call without any prior initialisation; they read
955    // only compile-time constants embedded in the library.
956    let major = unsafe { crate::ffi::cobre_highs_version_major() };
957    let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
958    let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
959    format!("{major}.{minor}.{patch}")
960}
961
962impl SolverInterface for HighsSolver {
963    fn name(&self) -> &'static str {
964        "HiGHS"
965    }
966
967    fn solver_name_version(&self) -> String {
968        format!("HiGHS {}", highs_version())
969    }
970
971    fn load_model(&mut self, template: &StageTemplate) {
972        let t0 = Instant::now();
973        // SAFETY:
974        // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
975        // - All pointer arguments point into owned `Vec` data that remains alive for the
976        //   duration of this call.
977        // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
978        //   template, alive for the duration of this borrow.
979        // - All slice lengths match the HiGHS API contract:
980        //   `num_col + 1` for a_start, `num_nz` for a_index and a_value,
981        //   `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
982        assert!(
983            i32::try_from(template.num_cols).is_ok(),
984            "num_cols {} overflows i32: LP exceeds HiGHS API limit",
985            template.num_cols
986        );
987        assert!(
988            i32::try_from(template.num_rows).is_ok(),
989            "num_rows {} overflows i32: LP exceeds HiGHS API limit",
990            template.num_rows
991        );
992        assert!(
993            i32::try_from(template.num_nz).is_ok(),
994            "num_nz {} overflows i32: LP exceeds HiGHS API limit",
995            template.num_nz
996        );
997        // SAFETY: All three values have been asserted to fit in i32 above.
998        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
999        let num_col = template.num_cols as i32;
1000        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1001        let num_row = template.num_rows as i32;
1002        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1003        let num_nz = template.num_nz as i32;
1004        let status = unsafe {
1005            ffi::cobre_highs_pass_lp(
1006                self.handle,
1007                num_col,
1008                num_row,
1009                num_nz,
1010                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1011                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1012                0.0, // objective offset
1013                template.objective.as_ptr(),
1014                template.col_lower.as_ptr(),
1015                template.col_upper.as_ptr(),
1016                template.row_lower.as_ptr(),
1017                template.row_upper.as_ptr(),
1018                template.col_starts.as_ptr(),
1019                template.row_indices.as_ptr(),
1020                template.values.as_ptr(),
1021            )
1022        };
1023
1024        assert_ne!(
1025            status,
1026            ffi::HIGHS_STATUS_ERROR,
1027            "cobre_highs_pass_lp failed with status {status}"
1028        );
1029
1030        self.num_cols = template.num_cols;
1031        self.num_rows = template.num_rows;
1032        self.has_model = true;
1033
1034        // Resize solution extraction buffers to match the new LP dimensions.
1035        // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
1036        self.col_value.resize(self.num_cols, 0.0);
1037        self.col_dual.resize(self.num_cols, 0.0);
1038        self.row_value.resize(self.num_rows, 0.0);
1039        self.row_dual.resize(self.num_rows, 0.0);
1040
1041        // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
1042        // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
1043        self.basis_col_i32.resize(self.num_cols, 0);
1044        self.basis_row_i32.resize(self.num_rows, 0);
1045        self.stats.total_load_model_time_seconds += t0.elapsed().as_secs_f64();
1046        self.stats.load_model_count += 1;
1047    }
1048
1049    fn add_rows(&mut self, rows: &RowBatch) {
1050        assert!(
1051            i32::try_from(rows.num_rows).is_ok(),
1052            "rows.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
1053            rows.num_rows
1054        );
1055        assert!(
1056            i32::try_from(rows.col_indices.len()).is_ok(),
1057            "rows nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
1058            rows.col_indices.len()
1059        );
1060        // SAFETY: Both values have been asserted to fit in i32 above.
1061        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1062        let num_new_row = rows.num_rows as i32;
1063        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1064        let num_new_nz = rows.col_indices.len() as i32;
1065
1066        // SAFETY:
1067        // - `self.handle` is a valid, non-null HiGHS pointer.
1068        // - All pointer arguments point into owned data alive for the duration of this call.
1069        // - `rows.row_starts` and `rows.col_indices` are `Vec<i32>` owned by the RowBatch,
1070        //   alive for the duration of this borrow.
1071        // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
1072        //   `num_rows` for lower/upper bounds.
1073        let status = unsafe {
1074            ffi::cobre_highs_add_rows(
1075                self.handle,
1076                num_new_row,
1077                rows.row_lower.as_ptr(),
1078                rows.row_upper.as_ptr(),
1079                num_new_nz,
1080                rows.row_starts.as_ptr(),
1081                rows.col_indices.as_ptr(),
1082                rows.values.as_ptr(),
1083            )
1084        };
1085
1086        assert_ne!(
1087            status,
1088            ffi::HIGHS_STATUS_ERROR,
1089            "cobre_highs_add_rows failed with status {status}"
1090        );
1091
1092        self.num_rows += rows.num_rows;
1093
1094        // Grow row-indexed solution extraction buffers to cover the new rows.
1095        self.row_value.resize(self.num_rows, 0.0);
1096        self.row_dual.resize(self.num_rows, 0.0);
1097
1098        // Grow basis row i32 buffer to cover the new rows.
1099        self.basis_row_i32.resize(self.num_rows, 0);
1100    }
1101
1102    fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1103        assert!(
1104            indices.len() == lower.len() && indices.len() == upper.len(),
1105            "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1106            indices.len(),
1107            lower.len(),
1108            upper.len()
1109        );
1110        if indices.is_empty() {
1111            return;
1112        }
1113
1114        assert!(
1115            i32::try_from(indices.len()).is_ok(),
1116            "set_row_bounds: indices.len() {} overflows i32",
1117            indices.len()
1118        );
1119        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1120        let num_entries = indices.len() as i32;
1121
1122        let t0 = Instant::now();
1123        // SAFETY:
1124        // - `self.handle` is a valid, non-null HiGHS pointer.
1125        // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
1126        //   alive for `'self`. Pointer is used immediately in the FFI call.
1127        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1128        // - `num_entries` equals the lengths of all three arrays.
1129        let status = unsafe {
1130            ffi::cobre_highs_change_rows_bounds_by_set(
1131                self.handle,
1132                num_entries,
1133                self.convert_to_i32_scratch(indices).as_ptr(),
1134                lower.as_ptr(),
1135                upper.as_ptr(),
1136            )
1137        };
1138
1139        assert_ne!(
1140            status,
1141            ffi::HIGHS_STATUS_ERROR,
1142            "cobre_highs_change_rows_bounds_by_set failed with status {status}"
1143        );
1144        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1145    }
1146
1147    fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1148        assert!(
1149            indices.len() == lower.len() && indices.len() == upper.len(),
1150            "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1151            indices.len(),
1152            lower.len(),
1153            upper.len()
1154        );
1155        if indices.is_empty() {
1156            return;
1157        }
1158
1159        assert!(
1160            i32::try_from(indices.len()).is_ok(),
1161            "set_col_bounds: indices.len() {} overflows i32",
1162            indices.len()
1163        );
1164        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1165        let num_entries = indices.len() as i32;
1166
1167        let t0 = Instant::now();
1168        // SAFETY:
1169        // - `self.handle` is a valid, non-null HiGHS pointer.
1170        // - Converted indices point into `self.scratch_i32`, alive for `'self`.
1171        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1172        // - `num_entries` equals the lengths of all three arrays.
1173        let status = unsafe {
1174            ffi::cobre_highs_change_cols_bounds_by_set(
1175                self.handle,
1176                num_entries,
1177                self.convert_to_i32_scratch(indices).as_ptr(),
1178                lower.as_ptr(),
1179                upper.as_ptr(),
1180            )
1181        };
1182
1183        assert_ne!(
1184            status,
1185            ffi::HIGHS_STATUS_ERROR,
1186            "cobre_highs_change_cols_bounds_by_set failed with status {status}"
1187        );
1188        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1189    }
1190
1191    /// # Preconditions
1192    ///
1193    /// When `basis` is `Some(b)`, the caller must size
1194    /// `b.row_status` to exactly `self.num_rows` (the current LP
1195    /// row count). Callers that grow the LP by adding rows are
1196    /// responsible for reconciling their basis to the new row
1197    /// count before invoking this method.
1198    fn solve(
1199        &mut self,
1200        basis: Option<&crate::types::Basis>,
1201    ) -> Result<SolutionView<'_>, SolverError> {
1202        assert!(
1203            self.has_model,
1204            "solve called without a loaded model — call load_model first"
1205        );
1206
1207        if let Some(basis) = basis {
1208            assert!(
1209                basis.col_status.len() == self.num_cols,
1210                "basis column count {} does not match LP column count {}",
1211                basis.col_status.len(),
1212                self.num_cols
1213            );
1214            debug_assert!(
1215                basis.row_status.len() >= self.num_rows,
1216                "solve(Some(&basis)): basis.row_status.len() ({}) < self.num_rows ({}); \
1217                 callers introducing new rows must reconcile basis (e.g. extend with \
1218                 NONBASIC_AT_LOWER for fresh inequality rows) before calling solve. \
1219                 The defensive BASIC padding below is incorrect for inequality slacks.",
1220                basis.row_status.len(),
1221                self.num_rows
1222            );
1223
1224            // Track every warm-start call as a basis offer for diagnostics.
1225            self.stats.basis_offered += 1;
1226
1227            // Copy raw i32 codes directly into the pre-allocated buffers — no enum
1228            // translation. Zero-copy warm-start path.
1229            self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
1230
1231            // Precondition: the caller must size `basis.row_status` to
1232            // exactly `self.num_rows`. The production caller reconciles
1233            // the basis size to the current row count before invoking
1234            // `solve(Some(&basis))`, so `basis_rows == lp_rows` always
1235            // holds in practice.
1236            //
1237            // For defensive robustness if a future caller offers a
1238            // mismatched basis:
1239            // - `basis_rows < lp_rows`: pad missing tail rows with BASIC.
1240            //   This is incorrect for newly added inequality rows, whose
1241            //   slacks should be non-basic at the appropriate bound;
1242            //   callers introducing new rows must reconcile the basis
1243            //   themselves before calling solve.
1244            // - `basis_rows > lp_rows`: truncate the trailing entries.
1245            //   The solver ignores any basis entry beyond `num_rows`.
1246            let basis_rows = basis.row_status.len();
1247            let lp_rows = self.num_rows;
1248            let copy_len = basis_rows.min(lp_rows);
1249            self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
1250            if lp_rows > basis_rows {
1251                self.basis_row_i32[basis_rows..lp_rows].fill(ffi::HIGHS_BASIS_STATUS_BASIC);
1252            }
1253
1254            // SAFETY:
1255            // - `self.handle` is a valid, non-null HiGHS pointer obtained from
1256            //   `cobre_highs_create()` and kept alive by `HighsSolver`.
1257            // - `basis_col_i32` was sized to `num_cols` in `load_model` and grown in
1258            //   `add_rows`; the slice written above covers exactly `num_cols` entries.
1259            // - `basis_row_i32` was sized to `num_rows` in `load_model` and grown in
1260            //   `add_rows`; the slice written above covers exactly `num_rows` entries
1261            //   (with missing rows extended to BASIC).
1262            let basis_set_start = Instant::now();
1263            let set_status = unsafe {
1264                ffi::cobre_highs_set_basis_non_alien(
1265                    self.handle,
1266                    self.basis_col_i32.as_ptr(),
1267                    self.basis_row_i32.as_ptr(),
1268                )
1269            };
1270            if set_status == ffi::HIGHS_STATUS_ERROR {
1271                // Non-alien rejected: the offered basis failed
1272                // `isBasisConsistent` (total_basic != num_row).
1273                // Count the rejection and surface it as a hard error.
1274                self.stats.basis_consistency_failures += 1;
1275                // Count basic entries from the already-populated buffers.
1276                //
1277                // `usize` -> `i64` is lossless for any basis that fits in memory:
1278                // realistic LP sizes are bounded well below 2^63.
1279                #[allow(clippy::cast_possible_wrap)]
1280                let col_basic = self.basis_col_i32[..self.num_cols]
1281                    .iter()
1282                    .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1283                    .count() as i64;
1284                #[allow(clippy::cast_possible_wrap)]
1285                let row_basic = self.basis_row_i32[..self.num_rows]
1286                    .iter()
1287                    .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1288                    .count() as i64;
1289                // Accumulate the elapsed time even on early return.
1290                self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1291                #[allow(clippy::cast_possible_wrap)]
1292                return Err(SolverError::BasisInconsistent {
1293                    num_row: self.num_rows as i64,
1294                    total_basic: col_basic + row_basic,
1295                    col_basic,
1296                    row_basic,
1297                });
1298            }
1299            self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1300        }
1301
1302        // Basis is installed (warm path) or not needed (cold path); run the simplex.
1303        // HiGHS retains its internal basis across consecutive solves on the same
1304        // LP shape, giving the backward pass ~15x fewer simplex iterations on
1305        // repeat solves at the same stage/opening.
1306        self.solve_inner()
1307    }
1308
1309    fn get_basis(&mut self, out: &mut crate::types::Basis) {
1310        assert!(
1311            self.has_model,
1312            "get_basis called without a loaded model — call load_model first"
1313        );
1314
1315        out.col_status.resize(self.num_cols, 0);
1316        out.row_status.resize(self.num_rows, 0);
1317
1318        // SAFETY:
1319        // - `self.handle` is a valid, non-null HiGHS pointer.
1320        // - `out.col_status` has been resized to `num_cols` entries above.
1321        // - `out.row_status` has been resized to `num_rows` entries above.
1322        // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
1323        let get_status = unsafe {
1324            ffi::cobre_highs_get_basis(
1325                self.handle,
1326                out.col_status.as_mut_ptr(),
1327                out.row_status.as_mut_ptr(),
1328            )
1329        };
1330
1331        assert_ne!(
1332            get_status,
1333            ffi::HIGHS_STATUS_ERROR,
1334            "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
1335        );
1336    }
1337
1338    fn statistics(&self) -> SolverStatistics {
1339        self.stats.clone()
1340    }
1341
1342    fn record_reconstruction_stats(&mut self) {
1343        self.stats.basis_reconstructions += 1;
1344    }
1345}
1346
1347/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1348///
1349/// Gated behind the `test-support` feature. The raw handle is intentionally not
1350/// part of the public API — callers use these methods to configure time/iteration
1351/// limits before a solve without going through the safe wrapper.
1352#[cfg(feature = "test-support")]
1353impl HighsSolver {
1354    /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1355    ///
1356    /// # Safety
1357    ///
1358    /// The returned pointer is valid for the lifetime of `self`. The caller must
1359    /// not store the pointer beyond that lifetime, must not call
1360    /// `cobre_highs_destroy` on it, and must not alias it across threads.
1361    #[must_use]
1362    pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1363        self.handle
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::HighsSolver;
1370    use crate::{
1371        SolverInterface,
1372        types::{Basis, RowBatch, StageTemplate},
1373    };
1374
1375    // Shared LP fixture from Solver Interface Testing SS1.1:
1376    // 3 variables, 2 structural constraints, 3 non-zeros.
1377    //
1378    //   min  0*x0 + 1*x1 + 50*x2
1379    //   s.t. x0            = 6   (state-fixing)
1380    //        2*x0 + x2     = 14  (power balance)
1381    //   x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1382    //
1383    // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1384    //   col_starts  = [0, 2, 2, 3]
1385    //   row_indices = [0, 1, 1]
1386    //   values      = [1.0, 2.0, 1.0]
1387    fn make_fixture_stage_template() -> StageTemplate {
1388        StageTemplate {
1389            num_cols: 3,
1390            num_rows: 2,
1391            num_nz: 3,
1392            col_starts: vec![0_i32, 2, 2, 3],
1393            row_indices: vec![0_i32, 1, 1],
1394            values: vec![1.0, 2.0, 1.0],
1395            col_lower: vec![0.0, 0.0, 0.0],
1396            col_upper: vec![10.0, f64::INFINITY, 8.0],
1397            objective: vec![0.0, 1.0, 50.0],
1398            row_lower: vec![6.0, 14.0],
1399            row_upper: vec![6.0, 14.0],
1400            n_state: 1,
1401            n_transfer: 0,
1402            n_dual_relevant: 1,
1403            n_hydro: 1,
1404            max_par_order: 0,
1405            col_scale: Vec::new(),
1406            row_scale: Vec::new(),
1407        }
1408    }
1409
1410    // Valid-inequality fixture from Solver Interface Testing SS1.2:
1411    // Row 1: -5*x0 + x1 >= 20  (col_indices [0,1], values [-5, 1])
1412    // Row 2:  3*x0 + x1 >= 80  (col_indices [0,1], values [ 3, 1])
1413    fn make_fixture_row_batch() -> RowBatch {
1414        RowBatch {
1415            num_rows: 2,
1416            row_starts: vec![0_i32, 2, 4],
1417            col_indices: vec![0_i32, 1, 0, 1],
1418            values: vec![-5.0, 1.0, 3.0, 1.0],
1419            row_lower: vec![20.0, 80.0],
1420            row_upper: vec![f64::INFINITY, f64::INFINITY],
1421        }
1422    }
1423
1424    #[test]
1425    fn test_highs_solver_create_and_name() {
1426        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1427        assert_eq!(solver.name(), "HiGHS");
1428        // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1429    }
1430
1431    #[test]
1432    fn test_highs_solver_send_bound() {
1433        fn assert_send<T: Send>() {}
1434        assert_send::<HighsSolver>();
1435    }
1436
1437    #[test]
1438    fn test_highs_solver_statistics_initial() {
1439        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1440        let stats = solver.statistics();
1441        assert_eq!(stats.solve_count, 0);
1442        assert_eq!(stats.success_count, 0);
1443        assert_eq!(stats.failure_count, 0);
1444        assert_eq!(stats.total_iterations, 0);
1445        assert_eq!(stats.retry_count, 0);
1446        assert_eq!(stats.total_solve_time_seconds, 0.0);
1447    }
1448
1449    #[test]
1450    fn test_highs_load_model_updates_dimensions() {
1451        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1452        let template = make_fixture_stage_template();
1453
1454        solver.load_model(&template);
1455
1456        assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1457        assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1458        assert_eq!(
1459            solver.col_value.len(),
1460            3,
1461            "col_value buffer must be resized to num_cols"
1462        );
1463        assert_eq!(
1464            solver.col_dual.len(),
1465            3,
1466            "col_dual buffer must be resized to num_cols"
1467        );
1468        assert_eq!(
1469            solver.row_value.len(),
1470            2,
1471            "row_value buffer must be resized to num_rows"
1472        );
1473        assert_eq!(
1474            solver.row_dual.len(),
1475            2,
1476            "row_dual buffer must be resized to num_rows"
1477        );
1478    }
1479
1480    #[test]
1481    fn test_highs_add_rows_updates_dimensions() {
1482        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1483        let template = make_fixture_stage_template();
1484        let cuts = make_fixture_row_batch();
1485
1486        solver.load_model(&template);
1487        solver.add_rows(&cuts);
1488
1489        // 2 structural rows + 2 appended rows = 4
1490        assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1491        assert_eq!(
1492            solver.row_dual.len(),
1493            4,
1494            "row_dual buffer must be resized to 4 after add_rows"
1495        );
1496        assert_eq!(
1497            solver.row_value.len(),
1498            4,
1499            "row_value buffer must be resized to 4 after add_rows"
1500        );
1501        // Columns unchanged
1502        assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1503    }
1504
1505    #[test]
1506    fn test_highs_set_row_bounds_no_panic() {
1507        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1508        let template = make_fixture_stage_template();
1509        solver.load_model(&template);
1510
1511        // Patch row 0 to equality at 4.0. Must complete without panic.
1512        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1513    }
1514
1515    #[test]
1516    fn test_highs_set_col_bounds_no_panic() {
1517        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1518        let template = make_fixture_stage_template();
1519        solver.load_model(&template);
1520
1521        // Patch column 1 lower bound to 10.0. Must complete without panic.
1522        solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1523    }
1524
1525    #[test]
1526    fn test_highs_set_bounds_empty_no_panic() {
1527        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1528        let template = make_fixture_stage_template();
1529        solver.load_model(&template);
1530
1531        // Empty patch slices should be short-circuited without any FFI call.
1532        solver.set_row_bounds(&[], &[], &[]);
1533        solver.set_col_bounds(&[], &[], &[]);
1534    }
1535
1536    /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1537    /// Optimal: x0=6, x1=0, x2=2, objective=100.
1538    #[test]
1539    fn test_highs_solve_basic_lp() {
1540        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1541        let template = make_fixture_stage_template();
1542        solver.load_model(&template);
1543
1544        let solution = solver
1545            .solve(None)
1546            .expect("solve() must succeed on a feasible LP");
1547
1548        assert!(
1549            (solution.objective - 100.0).abs() < 1e-8,
1550            "objective must be 100.0, got {}",
1551            solution.objective
1552        );
1553        assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1554        assert!(
1555            (solution.primal[0] - 6.0).abs() < 1e-8,
1556            "primal[0] (x0) must be 6.0, got {}",
1557            solution.primal[0]
1558        );
1559        assert!(
1560            (solution.primal[1] - 0.0).abs() < 1e-8,
1561            "primal[1] (x1) must be 0.0, got {}",
1562            solution.primal[1]
1563        );
1564        assert!(
1565            (solution.primal[2] - 2.0).abs() < 1e-8,
1566            "primal[2] (x2) must be 2.0, got {}",
1567            solution.primal[2]
1568        );
1569    }
1570
1571    /// SS1.2: after adding two valid inequalities to SS1.1, optimal objective = 162.
1572    /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1573    /// Obj = 0*6 + 1*62 + 50*2 = 162.
1574    #[test]
1575    fn test_highs_solve_with_cuts() {
1576        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1577        let template = make_fixture_stage_template();
1578        let cuts = make_fixture_row_batch();
1579        solver.load_model(&template);
1580        solver.add_rows(&cuts);
1581
1582        let solution = solver
1583            .solve(None)
1584            .expect("solve() must succeed on a feasible LP with cuts");
1585
1586        assert!(
1587            (solution.objective - 162.0).abs() < 1e-8,
1588            "objective must be 162.0, got {}",
1589            solution.objective
1590        );
1591        assert!(
1592            (solution.primal[0] - 6.0).abs() < 1e-8,
1593            "primal[0] must be 6.0, got {}",
1594            solution.primal[0]
1595        );
1596        assert!(
1597            (solution.primal[1] - 62.0).abs() < 1e-8,
1598            "primal[1] must be 62.0, got {}",
1599            solution.primal[1]
1600        );
1601        assert!(
1602            (solution.primal[2] - 2.0).abs() < 1e-8,
1603            "primal[2] must be 2.0, got {}",
1604            solution.primal[2]
1605        );
1606    }
1607
1608    /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1609    /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1610    #[test]
1611    fn test_highs_solve_after_rhs_patch() {
1612        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1613        let template = make_fixture_stage_template();
1614        let cuts = make_fixture_row_batch();
1615        solver.load_model(&template);
1616        solver.add_rows(&cuts);
1617
1618        // Patch row 0 (x0=6 equality) to x0=4.
1619        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1620
1621        let solution = solver
1622            .solve(None)
1623            .expect("solve() must succeed after RHS patch");
1624
1625        assert!(
1626            (solution.objective - 368.0).abs() < 1e-8,
1627            "objective must be 368.0, got {}",
1628            solution.objective
1629        );
1630    }
1631
1632    /// After two successful solves, statistics must reflect both.
1633    #[test]
1634    fn test_highs_solve_statistics_increment() {
1635        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1636        let template = make_fixture_stage_template();
1637        solver.load_model(&template);
1638
1639        solver.solve(None).expect("first solve must succeed");
1640        solver.solve(None).expect("second solve must succeed");
1641
1642        let stats = solver.statistics();
1643        assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1644        assert_eq!(stats.success_count, 2, "success_count must be 2");
1645        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1646        assert!(
1647            stats.total_iterations > 0,
1648            "total_iterations must be positive"
1649        );
1650    }
1651
1652    /// After a cold solve, statistics counters must reflect the single solve.
1653    #[test]
1654    fn test_highs_solve_preserves_stats() {
1655        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1656        let template = make_fixture_stage_template();
1657        solver.load_model(&template);
1658        solver.solve(None).expect("solve must succeed");
1659
1660        let stats = solver.statistics();
1661        assert_eq!(
1662            stats.solve_count, 1,
1663            "solve_count must be 1 after one solve"
1664        );
1665        assert_eq!(
1666            stats.success_count, 1,
1667            "success_count must be 1 after one successful solve"
1668        );
1669        assert!(
1670            stats.total_iterations > 0,
1671            "total_iterations must be positive after a successful solve"
1672        );
1673    }
1674
1675    /// The first solve must report a positive iteration count.
1676    #[test]
1677    fn test_highs_solve_iterations_positive() {
1678        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1679        let template = make_fixture_stage_template();
1680        solver.load_model(&template);
1681
1682        let solution = solver.solve(None).expect("solve must succeed");
1683        assert!(
1684            solution.iterations > 0,
1685            "iterations must be positive, got {}",
1686            solution.iterations
1687        );
1688    }
1689
1690    /// The first solve must report a positive wall-clock time.
1691    #[test]
1692    fn test_highs_solve_time_positive() {
1693        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1694        let template = make_fixture_stage_template();
1695        solver.load_model(&template);
1696
1697        let solution = solver.solve(None).expect("solve must succeed");
1698        assert!(
1699            solution.solve_time_seconds > 0.0,
1700            "solve_time_seconds must be positive, got {}",
1701            solution.solve_time_seconds
1702        );
1703    }
1704
1705    /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1706    /// `failure_count==0`, and `total_iterations` > 0.
1707    #[test]
1708    fn test_highs_solve_statistics_single() {
1709        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1710        let template = make_fixture_stage_template();
1711        solver.load_model(&template);
1712
1713        solver.solve(None).expect("solve must succeed");
1714
1715        let stats = solver.statistics();
1716        assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1717        assert_eq!(stats.success_count, 1, "success_count must be 1");
1718        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1719        assert!(
1720            stats.total_iterations > 0,
1721            "total_iterations must be positive after a successful solve"
1722        );
1723    }
1724
1725    /// After `load_model` + `solve()`, `get_basis` must return i32 codes
1726    /// that are all valid `HiGHS` basis status values (0..=4).
1727    #[test]
1728    fn test_get_basis_valid_status_codes() {
1729        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1730        let template = make_fixture_stage_template();
1731        solver.load_model(&template);
1732        solver
1733            .solve(None)
1734            .expect("solve must succeed before get_basis");
1735
1736        let mut basis = Basis::new(0, 0);
1737        solver.get_basis(&mut basis);
1738
1739        for &code in &basis.col_status {
1740            assert!(
1741                (0..=4).contains(&code),
1742                "col_status code {code} is outside valid HiGHS range 0..=4"
1743            );
1744        }
1745        for &code in &basis.row_status {
1746            assert!(
1747                (0..=4).contains(&code),
1748                "row_status code {code} is outside valid HiGHS range 0..=4"
1749            );
1750        }
1751    }
1752
1753    /// Starting from an empty `Basis`, `get_basis` must resize the output
1754    /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
1755    #[test]
1756    fn test_get_basis_resizes_output() {
1757        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1758        let template = make_fixture_stage_template();
1759        solver.load_model(&template);
1760        solver
1761            .solve(None)
1762            .expect("solve must succeed before get_basis");
1763
1764        let mut basis = Basis::new(0, 0);
1765        assert_eq!(
1766            basis.col_status.len(),
1767            0,
1768            "initial col_status must be empty"
1769        );
1770        assert_eq!(
1771            basis.row_status.len(),
1772            0,
1773            "initial row_status must be empty"
1774        );
1775
1776        solver.get_basis(&mut basis);
1777
1778        assert_eq!(
1779            basis.col_status.len(),
1780            3,
1781            "col_status must be resized to 3 (num_cols of SS1.1)"
1782        );
1783        assert_eq!(
1784            basis.row_status.len(),
1785            2,
1786            "row_status must be resized to 2 (num_rows of SS1.1)"
1787        );
1788    }
1789
1790    /// Warm-start via `solve(Some(&basis))` on the same LP must reproduce
1791    /// the optimal objective and complete in at most 1 simplex iteration.
1792    #[test]
1793    fn test_solve_warm_start_reproduces_cold_objective() {
1794        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1795        let template = make_fixture_stage_template();
1796        solver.load_model(&template);
1797        solver.solve(None).expect("cold-start solve must succeed");
1798
1799        let mut basis = Basis::new(0, 0);
1800        solver.get_basis(&mut basis);
1801
1802        // Reload the same model to reset HiGHS internal state.
1803        solver.load_model(&template);
1804        let result = solver
1805            .solve(Some(&basis))
1806            .expect("warm-start solve must succeed");
1807
1808        assert!(
1809            (result.objective - 100.0).abs() < 1e-8,
1810            "warm-start objective must be 100.0, got {}",
1811            result.objective
1812        );
1813        assert!(
1814            result.iterations <= 1,
1815            "warm-start from exact basis must use at most 1 iteration, got {}",
1816            result.iterations
1817        );
1818
1819        let stats = solver.statistics();
1820        assert_eq!(
1821            stats.basis_consistency_failures, 0,
1822            "basis_consistency_failures must be 0 when raw basis is accepted, got {}",
1823            stats.basis_consistency_failures
1824        );
1825        assert_eq!(
1826            stats.basis_offered, 1,
1827            "basis_offered must be 1 after one warm-start call"
1828        );
1829    }
1830
1831    /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
1832    /// `solve(Some(&basis))` must extend missing rows as Basic and solve correctly.
1833    /// SS1.2 objective with both cuts active is 162.0.
1834    ///
1835    /// This test exercises the defensive BASIC-padding fallback path,
1836    /// which the production caller never hits because it reconciles the
1837    /// basis to the LP row count before invoking `solve`. The
1838    /// `debug_assert!` in `solve` would fire on this fallback path, so
1839    /// the test runs only when `debug_assertions` is disabled.
1840    #[cfg(not(debug_assertions))]
1841    #[test]
1842    fn test_solve_warm_start_extends_missing_rows_as_basic() {
1843        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1844        let template = make_fixture_stage_template();
1845        let cuts = make_fixture_row_batch();
1846
1847        // First solve on 2-row LP to capture a 2-row basis.
1848        solver.load_model(&template);
1849        solver.solve(None).expect("SS1.1 solve must succeed");
1850        let mut basis = Basis::new(0, 0);
1851        solver.get_basis(&mut basis);
1852        assert_eq!(
1853            basis.row_status.len(),
1854            2,
1855            "captured basis must have 2 row statuses"
1856        );
1857
1858        // Reload model and add 2 cuts to get a 4-row LP.
1859        solver.load_model(&template);
1860        solver.add_rows(&cuts);
1861        assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
1862
1863        // Warm-start with the 2-row basis; extra rows are extended as Basic.
1864        let result = solver
1865            .solve(Some(&basis))
1866            .expect("solve with dimension-mismatched basis must succeed");
1867
1868        assert!(
1869            (result.objective - 162.0).abs() < 1e-8,
1870            "objective with both cuts active must be 162.0, got {}",
1871            result.objective
1872        );
1873    }
1874
1875    /// Non-alien path accepts a self-extracted basis: counter must stay at zero.
1876    ///
1877    /// Solves SS1.1 cold, extracts the optimal basis, reloads the model, and
1878    /// warm-starts via `solve(Some(&basis))`.  The non-alien FFI call
1879    /// (`cobre_highs_set_basis_non_alien`) should accept a basis that was just
1880    /// produced by `HiGHS` itself, so `basis_consistency_failures` must not
1881    /// increase.
1882    #[test]
1883    fn test_solve_warm_start_non_alien_success() {
1884        // Arrange
1885        let template = make_fixture_stage_template();
1886        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1887        solver.load_model(&template);
1888        let _ = solver.solve(None).expect("cold-start solve must succeed");
1889        let mut basis = Basis::new(template.num_cols, template.num_rows);
1890        solver.get_basis(&mut basis);
1891
1892        // Reload model so HiGHS internal state is fresh, then warm-start.
1893        solver.load_model(&template);
1894        let before = solver.statistics();
1895
1896        // Act
1897        let _ = solver
1898            .solve(Some(&basis))
1899            .expect("warm-start solve must succeed with self-extracted basis");
1900
1901        // Assert
1902        let after = solver.statistics();
1903        assert_eq!(
1904            after.basis_consistency_failures - before.basis_consistency_failures,
1905            0,
1906            "non-alien path should accept a self-extracted basis; consistency failures delta must be 0"
1907        );
1908    }
1909
1910    /// `solve(Some(&basis))` returns `Err(SolverError::BasisInconsistent)` when given
1911    /// an inconsistent basis instead of silently falling back to the alien setter.
1912    ///
1913    /// Builds a deliberately inconsistent basis (all column statuses set to
1914    /// `HIGHS_BASIS_STATUS_BASIC`, all row statuses `HIGHS_BASIS_STATUS_BASIC`).
1915    /// For the 3-column, 2-row SS1.1 LP this yields 5 basic variables against a
1916    /// rank of 2, which `cobre_highs_set_basis_non_alien` rejects with
1917    /// `HIGHS_STATUS_ERROR`.  The error is surfaced as a hard `Err` and
1918    /// `basis_consistency_failures` increments by 1.
1919    ///
1920    /// After the call:
1921    /// - `basis_consistency_failures` increments by 1.
1922    /// - The result is `Err(SolverError::BasisInconsistent { num_row: 2,
1923    ///   total_basic: 5, col_basic: 3, row_basic: 2 })`.
1924    #[test]
1925    fn test_solve_warm_start_rejects_inconsistent_basis() {
1926        use crate::ffi;
1927        use crate::types::SolverError;
1928
1929        // Arrange: non-alien setter is now the only warm-start path.
1930        let template = make_fixture_stage_template();
1931        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1932
1933        solver.load_model(&template);
1934
1935        // Build a deliberately inconsistent basis: all BASIC (5 basics, rank 2).
1936        let mut bad_basis = Basis::new(template.num_cols, template.num_rows);
1937        bad_basis
1938            .col_status
1939            .iter_mut()
1940            .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
1941        bad_basis
1942            .row_status
1943            .iter_mut()
1944            .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
1945
1946        let before = solver.statistics();
1947
1948        // Act — convert result to a form that does not borrow `solver`.
1949        // `SolutionView<'_>` borrows `solver`'s internal buffers; calling
1950        // `statistics()` afterwards would overlap borrows.  On the error path
1951        // `SolverError` contains no solver references, so mapping Ok → () breaks
1952        // the mutable borrow before the statistics call.
1953        let err_variant: Result<(), SolverError> = solver.solve(Some(&bad_basis)).map(|_| ());
1954
1955        // Assert counters — the mutable borrow from solve(Some(&bad_basis)) is gone.
1956        let after = solver.statistics();
1957        assert_eq!(
1958            after.basis_consistency_failures - before.basis_consistency_failures,
1959            1,
1960            "basis_consistency_failures must increment by 1 for an overcounted basis"
1961        );
1962
1963        // Assert the returned error.
1964        match err_variant {
1965            Err(SolverError::BasisInconsistent {
1966                num_row,
1967                total_basic,
1968                col_basic,
1969                row_basic,
1970            }) => {
1971                assert_eq!(num_row, 2, "num_row must match LP row count");
1972                assert_eq!(total_basic, 5, "total_basic must be col_basic + row_basic");
1973                assert_eq!(col_basic, 3, "col_basic must count BASIC columns");
1974                assert_eq!(row_basic, 2, "row_basic must count BASIC rows");
1975            }
1976            other => panic!(
1977                "expected Err(SolverError::BasisInconsistent {{ num_row: 2, total_basic: 5, \
1978                 col_basic: 3, row_basic: 2 }}), got {other:?}"
1979            ),
1980        }
1981    }
1982
1983    /// `terminal_status_dual_scratch` and `terminal_status_primal_scratch` are
1984    /// initialized as empty `Vec`s in the constructor and retain their capacity
1985    /// across repeated `resize` calls, matching the pattern used by
1986    /// `scratch_i32`, `basis_col_i32`, and `basis_row_i32`.
1987    ///
1988    /// This test directly exercises the `resize`-reuse invariant without depending
1989    /// on a specific `HiGHS` model status. The `UNBOUNDED_OR_INFEASIBLE` branch in
1990    /// `interpret_terminal_status` calls `self.terminal_status_dual_scratch.resize(num_rows, 0.0)`;
1991    /// we verify here that repeated `resize` calls grow but never shrink capacity.
1992    ///
1993    /// The LP: 3-column, 2-row SS1.1 fixture. After `load_model`, `num_rows=2` and
1994    /// `num_cols=3`. We simulate two scratch-buffer resize cycles and verify capacity
1995    /// is monotonically non-decreasing.
1996    #[test]
1997    fn interpret_terminal_status_reuses_scratch() {
1998        let template = make_fixture_stage_template();
1999        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2000
2001        // Verify that scratch fields start empty (Vec::new() in constructor).
2002        assert_eq!(
2003            solver.terminal_status_dual_scratch.capacity(),
2004            0,
2005            "dual scratch must start with capacity 0 (Vec::new() in constructor)"
2006        );
2007        assert_eq!(
2008            solver.terminal_status_primal_scratch.capacity(),
2009            0,
2010            "primal scratch must start with capacity 0 (Vec::new() in constructor)"
2011        );
2012
2013        // Load model to establish num_rows=2 and num_cols=3.
2014        solver.load_model(&template);
2015
2016        // Simulate what interpret_terminal_status does in UNBOUNDED_OR_INFEASIBLE branch:
2017        // resize dual scratch to num_rows, primal scratch to num_cols.
2018        solver
2019            .terminal_status_dual_scratch
2020            .resize(solver.num_rows, 0.0);
2021        solver
2022            .terminal_status_primal_scratch
2023            .resize(solver.num_cols, 0.0);
2024
2025        let cap_dual_after_first = solver.terminal_status_dual_scratch.capacity();
2026        let cap_primal_after_first = solver.terminal_status_primal_scratch.capacity();
2027
2028        assert!(
2029            cap_dual_after_first >= solver.num_rows,
2030            "dual scratch capacity {cap_dual_after_first} must be >= num_rows {} after first resize",
2031            solver.num_rows,
2032        );
2033        assert!(
2034            cap_primal_after_first >= solver.num_cols,
2035            "primal scratch capacity {cap_primal_after_first} must be >= num_cols {} after first resize",
2036            solver.num_cols,
2037        );
2038
2039        // Second resize to the same size: capacity must not decrease (heap retained).
2040        solver
2041            .terminal_status_dual_scratch
2042            .resize(solver.num_rows, 0.0);
2043        solver
2044            .terminal_status_primal_scratch
2045            .resize(solver.num_cols, 0.0);
2046
2047        let cap_dual_after_second = solver.terminal_status_dual_scratch.capacity();
2048        let cap_primal_after_second = solver.terminal_status_primal_scratch.capacity();
2049
2050        assert!(
2051            cap_dual_after_second >= cap_dual_after_first,
2052            "dual scratch capacity must not decrease: {cap_dual_after_second} < {cap_dual_after_first}",
2053        );
2054        assert!(
2055            cap_primal_after_second >= cap_primal_after_first,
2056            "primal scratch capacity must not decrease: {cap_primal_after_second} < {cap_primal_after_first}",
2057        );
2058    }
2059}
2060
2061// ─── Research verification tests for non-optimal HiGHS model statuses ────
2062//
2063// These tests verify LP formulations that reliably trigger non-optimal
2064// HiGHS model statuses. They use the raw FFI layer to set options not
2065// exposed through SolverInterface and confirm the expected model status.
2066//
2067// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
2068// heuristic solves it without entering the simplex loop, so time/iteration
2069// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
2070#[cfg(test)]
2071#[allow(clippy::doc_markdown)]
2072mod research_tests {
2073    // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
2074    // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
2075    // produce ITERATION_LIMIT.
2076
2077    // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
2078    //
2079    // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
2080    // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
2081    //
2082    // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2083    unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
2084        use crate::ffi;
2085        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2086        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2087        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2088        let row_lower: [f64; 2] = [6.0, 14.0];
2089        let row_upper: [f64; 2] = [6.0, 14.0];
2090        let a_start: [i32; 4] = [0, 2, 2, 3];
2091        let a_index: [i32; 3] = [0, 1, 1];
2092        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2093        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2094        let status = unsafe {
2095            ffi::cobre_highs_pass_lp(
2096                highs,
2097                3,
2098                2,
2099                3,
2100                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2101                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2102                0.0,
2103                col_cost.as_ptr(),
2104                col_lower.as_ptr(),
2105                col_upper.as_ptr(),
2106                row_lower.as_ptr(),
2107                row_upper.as_ptr(),
2108                a_start.as_ptr(),
2109                a_index.as_ptr(),
2110                a_value.as_ptr(),
2111            )
2112        };
2113        assert_eq!(
2114            status,
2115            ffi::HIGHS_STATUS_OK,
2116            "research_load_ss11_lp pass_lp failed"
2117        );
2118    }
2119
2120    /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
2121    ///
2122    /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
2123    /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
2124    /// loop, making limits ineffective. This test documents that behavior.
2125    #[test]
2126    fn test_research_probe_limit_status_on_ss11_lp() {
2127        use crate::ffi;
2128
2129        // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
2130        let highs = unsafe { ffi::cobre_highs_create() };
2131        assert!(!highs.is_null());
2132        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2133        unsafe { research_load_ss11_lp(highs) };
2134        let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2135        let run_status = unsafe { ffi::cobre_highs_run(highs) };
2136        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2137        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2138        eprintln!(
2139            "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2140        );
2141        unsafe { ffi::cobre_highs_destroy(highs) };
2142
2143        // SS1.1 with iteration_limit=0: same result, need a larger LP.
2144        let highs = unsafe { ffi::cobre_highs_create() };
2145        assert!(!highs.is_null());
2146        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2147        unsafe { research_load_ss11_lp(highs) };
2148        let _ = unsafe {
2149            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2150        };
2151        let run_status = unsafe { ffi::cobre_highs_run(highs) };
2152        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2153        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2154        eprintln!(
2155            "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2156        );
2157        unsafe { ffi::cobre_highs_destroy(highs) };
2158    }
2159
2160    /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
2161    /// iterations and cannot be solved by crash alone.
2162    ///
2163    /// LP (larger_lp):
2164    ///   min  x0 + x1 + x2 + x3 + x4
2165    ///   s.t. x0 + x1              >= 10
2166    ///        x1 + x2              >= 8
2167    ///        x2 + x3              >= 6
2168    ///        x3 + x4              >= 4
2169    ///   x_i in [0, 100], i = 0..4
2170    ///
2171    /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
2172    ///   col 0: rows [0]       -> a_start[0]=0, a_start[1]=1
2173    ///   col 1: rows [0,1]     -> a_start[2]=3
2174    ///   col 2: rows [1,2]     -> a_start[3]=5
2175    ///   col 3: rows [2,3]     -> a_start[4]=7
2176    ///   col 4: rows [3]       -> a_start[5]=8
2177    ///
2178    /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2179    unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
2180        use crate::ffi;
2181        let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
2182        let col_lower: [f64; 5] = [0.0; 5];
2183        let col_upper: [f64; 5] = [100.0; 5];
2184        let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
2185        let row_upper: [f64; 4] = [f64::INFINITY; 4];
2186        // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
2187        let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
2188        let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
2189        let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
2190        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2191        let status = unsafe {
2192            ffi::cobre_highs_pass_lp(
2193                highs,
2194                5,
2195                4,
2196                8,
2197                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2198                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2199                0.0,
2200                col_cost.as_ptr(),
2201                col_lower.as_ptr(),
2202                col_upper.as_ptr(),
2203                row_lower.as_ptr(),
2204                row_upper.as_ptr(),
2205                a_start.as_ptr(),
2206                a_index.as_ptr(),
2207                a_value.as_ptr(),
2208            )
2209        };
2210        assert_eq!(
2211            status,
2212            ffi::HIGHS_STATUS_OK,
2213            "research_load_larger_lp pass_lp failed"
2214        );
2215    }
2216
2217    /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
2218    ///
2219    /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
2220    /// crash. HiGHS checks the time limit at entry to the simplex loop.
2221    /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
2222    ///
2223    /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
2224    /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
2225    #[test]
2226    fn test_research_time_limit_zero_triggers_time_limit_status() {
2227        use crate::ffi;
2228
2229        let highs = unsafe { ffi::cobre_highs_create() };
2230        assert!(!highs.is_null());
2231        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2232        unsafe { research_load_larger_lp(highs) };
2233
2234        let opt_status =
2235            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2236        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2237
2238        let run_status = unsafe { ffi::cobre_highs_run(highs) };
2239        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2240
2241        eprintln!(
2242            "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2243        );
2244
2245        assert_eq!(
2246            run_status,
2247            ffi::HIGHS_STATUS_WARNING,
2248            "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2249        );
2250        assert_eq!(
2251            model_status,
2252            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
2253            "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
2254        );
2255
2256        unsafe { ffi::cobre_highs_destroy(highs) };
2257    }
2258
2259    /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
2260    ///
2261    /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
2262    /// the crash phase does not solve it, and the iteration limit check fires.
2263    ///
2264    /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
2265    /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
2266    /// HighsModelStatus::kIterationLimit, iteration count = 0.
2267    #[test]
2268    fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
2269        use crate::ffi;
2270
2271        let highs = unsafe { ffi::cobre_highs_create() };
2272        assert!(!highs.is_null());
2273        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2274        // Disable presolve so crash cannot solve LP without simplex iterations.
2275        unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
2276        unsafe { research_load_larger_lp(highs) };
2277
2278        let opt_status = unsafe {
2279            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2280        };
2281        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2282
2283        let run_status = unsafe { ffi::cobre_highs_run(highs) };
2284        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2285
2286        eprintln!(
2287            "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2288        );
2289
2290        assert_eq!(
2291            run_status,
2292            ffi::HIGHS_STATUS_WARNING,
2293            "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2294        );
2295        assert_eq!(
2296            model_status,
2297            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
2298            "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
2299        );
2300
2301        unsafe { ffi::cobre_highs_destroy(highs) };
2302    }
2303
2304    /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
2305    ///
2306    /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
2307    /// and presolve disabled, HiGHS halts at the crash-point solution.
2308    /// Both tests record objective availability for documentation.
2309    #[test]
2310    fn test_research_partial_solution_availability() {
2311        use crate::ffi;
2312
2313        // TIME_LIMIT: observe objective after halting at time check
2314        {
2315            let highs = unsafe { ffi::cobre_highs_create() };
2316            assert!(!highs.is_null());
2317            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2318            unsafe { research_load_larger_lp(highs) };
2319            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2320            unsafe { ffi::cobre_highs_run(highs) };
2321
2322            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2323            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2324            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
2325            eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
2326            unsafe { ffi::cobre_highs_destroy(highs) };
2327        }
2328
2329        // ITERATION_LIMIT: observe objective at crash point
2330        {
2331            let highs = unsafe { ffi::cobre_highs_create() };
2332            assert!(!highs.is_null());
2333            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2334            unsafe {
2335                ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
2336            };
2337            unsafe { research_load_larger_lp(highs) };
2338            unsafe {
2339                ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2340            };
2341            unsafe { ffi::cobre_highs_run(highs) };
2342
2343            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2344            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2345            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2346            eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
2347            unsafe { ffi::cobre_highs_destroy(highs) };
2348        }
2349    }
2350
2351    /// Verify restore_default_settings: solve with iteration_limit=0, then solve
2352    /// without limit after restoring defaults. The second solve must succeed optimally.
2353    #[test]
2354    fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
2355        use crate::ffi;
2356
2357        let highs = unsafe { ffi::cobre_highs_create() };
2358        assert!(!highs.is_null());
2359
2360        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2361
2362        // Apply cobre defaults (mirror HighsSolver::new() configuration).
2363        unsafe {
2364            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2365            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2366            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2367            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2368            ffi::cobre_highs_set_double_option(
2369                highs,
2370                c"primal_feasibility_tolerance".as_ptr(),
2371                1e-7,
2372            );
2373            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2374        }
2375
2376        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2377        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2378        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2379        let row_lower: [f64; 2] = [6.0, 14.0];
2380        let row_upper: [f64; 2] = [6.0, 14.0];
2381        let a_start: [i32; 4] = [0, 2, 2, 3];
2382        let a_index: [i32; 3] = [0, 1, 1];
2383        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2384
2385        // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2386        unsafe {
2387            ffi::cobre_highs_pass_lp(
2388                highs,
2389                3,
2390                2,
2391                3,
2392                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2393                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2394                0.0,
2395                col_cost.as_ptr(),
2396                col_lower.as_ptr(),
2397                col_upper.as_ptr(),
2398                row_lower.as_ptr(),
2399                row_upper.as_ptr(),
2400                a_start.as_ptr(),
2401                a_index.as_ptr(),
2402                a_value.as_ptr(),
2403            );
2404            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2405            ffi::cobre_highs_run(highs);
2406        }
2407        let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2408        assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2409
2410        // Restore default settings (mirror restore_default_settings()).
2411        unsafe {
2412            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2413            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2414            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2415            ffi::cobre_highs_set_double_option(
2416                highs,
2417                c"primal_feasibility_tolerance".as_ptr(),
2418                1e-7,
2419            );
2420            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2421            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2422            ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2423            // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2424            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2425        }
2426
2427        // Second solve on the same model: must reach OPTIMAL.
2428        unsafe { ffi::cobre_highs_clear_solver(highs) };
2429        unsafe { ffi::cobre_highs_run(highs) };
2430        let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2431        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2432        assert_eq!(
2433            status2,
2434            ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2435            "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2436        );
2437        assert!(
2438            (obj - 100.0).abs() < 1e-8,
2439            "objective after restore must be 100.0, got {obj}"
2440        );
2441
2442        unsafe { ffi::cobre_highs_destroy(highs) };
2443    }
2444
2445    /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2446    ///
2447    /// This verifies that limiting to a small but non-zero number of iterations
2448    /// also works, providing an alternative formulation for triggering the same status.
2449    #[test]
2450    fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2451        use crate::ffi;
2452
2453        let highs = unsafe { ffi::cobre_highs_create() };
2454        assert!(!highs.is_null());
2455
2456        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2457
2458        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2459        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2460        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2461        let row_lower: [f64; 2] = [6.0, 14.0];
2462        let row_upper: [f64; 2] = [6.0, 14.0];
2463        let a_start: [i32; 4] = [0, 2, 2, 3];
2464        let a_index: [i32; 3] = [0, 1, 1];
2465        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2466
2467        unsafe {
2468            ffi::cobre_highs_pass_lp(
2469                highs,
2470                3,
2471                2,
2472                3,
2473                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2474                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2475                0.0,
2476                col_cost.as_ptr(),
2477                col_lower.as_ptr(),
2478                col_upper.as_ptr(),
2479                row_lower.as_ptr(),
2480                row_upper.as_ptr(),
2481                a_start.as_ptr(),
2482                a_index.as_ptr(),
2483                a_value.as_ptr(),
2484            );
2485            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2486            ffi::cobre_highs_run(highs);
2487        }
2488
2489        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2490        eprintln!("iteration_limit=1 model_status: {model_status}");
2491        // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2492        // We record both possibilities for the research document.
2493        assert!(
2494            model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2495                || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2496            "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2497        );
2498
2499        unsafe { ffi::cobre_highs_destroy(highs) };
2500    }
2501
2502    /// Verify that `HighsSolver` correctly maps unbounded and infeasible statuses.
2503    ///
2504    /// With presolve=off and dual simplex (the default `HighsSolver` configuration),
2505    /// HiGHS returns `HIGHS_MODEL_STATUS_UNBOUNDED` (10) for unbounded LPs and
2506    /// `HIGHS_MODEL_STATUS_INFEASIBLE` (8) for infeasible LPs. Both are mapped to
2507    /// the appropriate `SolverError` variants without entering the
2508    /// `UNBOUNDED_OR_INFEASIBLE` probe branch.
2509    ///
2510    /// Note: `HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE` (9) is returned only by
2511    /// IPM (`IpxWrapper.cpp:317`) when it detects dual infeasibility, or when
2512    /// `allow_unbounded_or_infeasible=true` is set with presolve=on. Neither
2513    /// condition occurs in the default `HighsSolver` configuration, so the
2514    /// `UNBOUNDED_OR_INFEASIBLE` branch serves as a safe fallback for retry paths
2515    /// that switch to IPM.
2516    #[test]
2517    fn test_research_verify_non_optimal_highs_status_mapping() {
2518        use super::super::HighsSolver;
2519        use crate::SolverInterface;
2520        use crate::types::SolverError;
2521        use crate::types::StageTemplate;
2522
2523        // Unbounded LP: min -x0 - x1, x0 + x1 >= 1, x0/x1 in [0, +inf).
2524        // With presolve=off and dual simplex, HiGHS returns UNBOUNDED (10).
2525        let unbounded_template = StageTemplate {
2526            num_cols: 2,
2527            num_rows: 1,
2528            num_nz: 2,
2529            col_starts: vec![0_i32, 1, 2],
2530            row_indices: vec![0_i32, 0],
2531            values: vec![1.0, 1.0],
2532            col_lower: vec![0.0, 0.0],
2533            col_upper: vec![f64::INFINITY, f64::INFINITY],
2534            objective: vec![-1.0, -1.0],
2535            row_lower: vec![1.0],
2536            row_upper: vec![f64::INFINITY],
2537            n_state: 1,
2538            n_transfer: 0,
2539            n_dual_relevant: 1,
2540            n_hydro: 0,
2541            max_par_order: 0,
2542            col_scale: Vec::new(),
2543            row_scale: Vec::new(),
2544        };
2545        let mut solver_unb = HighsSolver::new().expect("HighsSolver::new() must succeed");
2546        solver_unb.load_model(&unbounded_template);
2547        let result_unb = solver_unb.solve(None).map(|_| ());
2548        assert!(
2549            matches!(result_unb, Err(SolverError::Unbounded)),
2550            "unbounded LP must return Err(SolverError::Unbounded), got {result_unb:?}"
2551        );
2552
2553        // Infeasible LP: x0 must equal 99 but is bounded to [0, 10].
2554        // HiGHS returns INFEASIBLE (8) directly; mapped to Err(SolverError::Infeasible).
2555        let infeasible_template = StageTemplate {
2556            num_cols: 1,
2557            num_rows: 1,
2558            num_nz: 1,
2559            col_starts: vec![0_i32, 1],
2560            row_indices: vec![0_i32],
2561            values: vec![1.0],
2562            col_lower: vec![0.0],
2563            col_upper: vec![10.0],
2564            objective: vec![0.0],
2565            row_lower: vec![99.0],
2566            row_upper: vec![99.0],
2567            n_state: 1,
2568            n_transfer: 0,
2569            n_dual_relevant: 1,
2570            n_hydro: 0,
2571            max_par_order: 0,
2572            col_scale: Vec::new(),
2573            row_scale: Vec::new(),
2574        };
2575        let mut solver_inf = HighsSolver::new().expect("HighsSolver::new() must succeed");
2576        solver_inf.load_model(&infeasible_template);
2577        let result_inf = solver_inf.solve(None).map(|_| ());
2578        assert!(
2579            matches!(result_inf, Err(SolverError::Infeasible)),
2580            "infeasible LP must return Err(SolverError::Infeasible), got {result_inf:?}"
2581        );
2582    }
2583}