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