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): primal simplex, no presolve, no parallelism, suppressed output, and
18//! tight feasibility tolerances. These defaults are optimised for repeated
19//! solves of small-to-medium LPs. Per-run parameters (time limit, iteration
20//! limit) are not set here -- those are applied by the caller before each solve.
21
22use std::ffi::CStr;
23use std::os::raw::c_void;
24use std::time::Instant;
25
26use crate::{
27    SolverInterface, ffi,
28    types::{RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate},
29};
30
31// ─── Default HiGHS configuration ─────────────────────────────────────────────
32//
33// The eight performance-tuned options applied at construction and restored after
34// each retry escalation. Keeping them in a single array eliminates per-option
35// error branches that are structurally impossible to trigger in tests (HiGHS
36// never rejects valid static option names).
37
38/// A typed `HiGHS` option value for the configuration table.
39enum OptionValue {
40    /// String option (`cobre_highs_set_string_option`).
41    Str(&'static CStr),
42    /// Integer option (`cobre_highs_set_int_option`).
43    Int(i32),
44    /// Boolean option (`cobre_highs_set_bool_option`).
45    Bool(i32),
46    /// Double option (`cobre_highs_set_double_option`).
47    Double(f64),
48}
49
50/// A named `HiGHS` option with its default value.
51struct DefaultOption {
52    name: &'static CStr,
53    value: OptionValue,
54}
55
56impl DefaultOption {
57    /// Applies this option to a `HiGHS` handle. Returns the `HiGHS` status code.
58    ///
59    /// # Safety
60    ///
61    /// `handle` must be a valid, non-null pointer from `cobre_highs_create()`.
62    unsafe fn apply(&self, handle: *mut c_void) -> i32 {
63        unsafe {
64            match &self.value {
65                OptionValue::Str(val) => {
66                    ffi::cobre_highs_set_string_option(handle, self.name.as_ptr(), val.as_ptr())
67                }
68                OptionValue::Int(val) => {
69                    ffi::cobre_highs_set_int_option(handle, self.name.as_ptr(), *val)
70                }
71                OptionValue::Bool(val) => {
72                    ffi::cobre_highs_set_bool_option(handle, self.name.as_ptr(), *val)
73                }
74                OptionValue::Double(val) => {
75                    ffi::cobre_highs_set_double_option(handle, self.name.as_ptr(), *val)
76                }
77            }
78        }
79    }
80}
81
82/// Performance-tuned default options (`HiGHS` Implementation SS4.1).
83///
84/// These eight options are applied at construction and restored after each retry
85/// escalation. `simplex_scale_strategy` is explicitly set to 2 (equilibration,
86/// the `HiGHS` default) so that `restore_default_settings()` returns it to a known
87/// state after retry levels that override it.
88fn default_options() -> [DefaultOption; 8] {
89    [
90        DefaultOption {
91            name: c"solver",
92            value: OptionValue::Str(c"simplex"),
93        },
94        DefaultOption {
95            name: c"simplex_strategy",
96            value: OptionValue::Int(4), // Primal simplex
97        },
98        DefaultOption {
99            name: c"simplex_scale_strategy",
100            value: OptionValue::Int(2), // Equilibration (HiGHS default)
101        },
102        DefaultOption {
103            name: c"presolve",
104            value: OptionValue::Str(c"off"),
105        },
106        DefaultOption {
107            name: c"parallel",
108            value: OptionValue::Str(c"off"),
109        },
110        DefaultOption {
111            name: c"output_flag",
112            value: OptionValue::Bool(0),
113        },
114        DefaultOption {
115            name: c"primal_feasibility_tolerance",
116            value: OptionValue::Double(1e-7),
117        },
118        DefaultOption {
119            name: c"dual_feasibility_tolerance",
120            value: OptionValue::Double(1e-7),
121        },
122    ]
123}
124
125/// `HiGHS` LP solver instance implementing [`SolverInterface`].
126///
127/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
128/// extraction, scratch i32 index conversion, and statistics accumulation.
129///
130/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
131/// when the instance is dropped.
132///
133/// # Example
134///
135/// ```rust
136/// use cobre_solver::{HighsSolver, SolverInterface};
137///
138/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
139/// assert_eq!(solver.name(), "HiGHS");
140/// ```
141pub struct HighsSolver {
142    /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
143    handle: *mut c_void,
144    /// Pre-allocated buffer for primal column values extracted after each solve.
145    /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
146    col_value: Vec<f64>,
147    /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
148    /// Resized in `load_model`.
149    col_dual: Vec<f64>,
150    /// Pre-allocated buffer for row primal values (constraint activity).
151    /// Resized in `load_model`.
152    row_value: Vec<f64>,
153    /// Pre-allocated buffer for row dual multipliers (shadow prices).
154    /// Resized in `load_model`.
155    row_dual: Vec<f64>,
156    /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
157    /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
158    /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
159    scratch_i32: Vec<i32>,
160    /// Pre-allocated i32 buffer for column basis status codes.
161    /// Reused across `solve_with_basis` and `get_basis` calls to avoid per-call allocation.
162    /// Resized in `load_model` to `num_cols`; never shrunk.
163    basis_col_i32: Vec<i32>,
164    /// Pre-allocated i32 buffer for row basis status codes.
165    /// Reused across `solve_with_basis` and `get_basis` calls to avoid per-call allocation.
166    /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
167    basis_row_i32: Vec<i32>,
168    /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
169    num_cols: usize,
170    /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
171    num_rows: usize,
172    /// Whether a model is currently loaded. Set to `true` in `load_model`,
173    /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
174    has_model: bool,
175    /// Accumulated solver statistics. Counters grow monotonically from zero;
176    /// not reset by `reset()`.
177    stats: SolverStatistics,
178}
179
180// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
181// handle is not thread-safe for concurrent access, but exclusive ownership is
182// maintained at all times -- exactly one `HighsSolver` instance owns each
183// handle and no shared references to the handle exist. Transferring the
184// `HighsSolver` to another thread (via `Send`) is safe because there is no
185// concurrent access; the new thread has exclusive ownership. `Sync` is
186// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
187unsafe impl Send for HighsSolver {}
188
189impl HighsSolver {
190    /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
191    ///
192    /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
193    /// the eight default options defined in `HiGHS` Implementation SS4.1:
194    ///
195    /// | Option                         | Value       | Type   |
196    /// |--------------------------------|-------------|--------|
197    /// | `solver`                       | `"simplex"` | string |
198    /// | `simplex_strategy`             | `4`         | int    |
199    /// | `simplex_scale_strategy`       | `2`         | int    |
200    /// | `presolve`                     | `"off"`     | string |
201    /// | `parallel`                     | `"off"`     | string |
202    /// | `output_flag`                  | `0`         | bool   |
203    /// | `primal_feasibility_tolerance` | `1e-7`      | double |
204    /// | `dual_feasibility_tolerance`   | `1e-7`      | double |
205    ///
206    /// # Errors
207    ///
208    /// Returns `Err(SolverError::InternalError { .. })` if:
209    /// - `cobre_highs_create()` returns a null pointer.
210    /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
211    ///
212    /// In both failure cases the `HiGHS` handle is destroyed before returning to
213    /// prevent a resource leak.
214    pub fn new() -> Result<Self, SolverError> {
215        // SAFETY: `cobre_highs_create` is a C function with no preconditions.
216        // It allocates and returns a new `HiGHS` instance, or null on allocation
217        // failure. The returned pointer is opaque and must be passed back to
218        // `HiGHS` API functions.
219        let handle = unsafe { ffi::cobre_highs_create() };
220
221        if handle.is_null() {
222            return Err(SolverError::InternalError {
223                message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
224                error_code: None,
225            });
226        }
227
228        // Apply performance-tuned configuration. On any failure, destroy the
229        // handle before returning to prevent a resource leak.
230        if let Err(e) = Self::apply_default_config(handle) {
231            // SAFETY: `handle` is a valid, non-null pointer obtained from
232            // `cobre_highs_create()` in this same function. It has not been
233            // passed to `cobre_highs_destroy()` yet. After this call, `handle`
234            // must not be used again -- this function returns immediately with Err.
235            unsafe { ffi::cobre_highs_destroy(handle) };
236            return Err(e);
237        }
238
239        Ok(Self {
240            handle,
241            col_value: Vec::new(),
242            col_dual: Vec::new(),
243            row_value: Vec::new(),
244            row_dual: Vec::new(),
245            scratch_i32: Vec::new(),
246            basis_col_i32: Vec::new(),
247            basis_row_i32: Vec::new(),
248            num_cols: 0,
249            num_rows: 0,
250            has_model: false,
251            stats: SolverStatistics::default(),
252        })
253    }
254
255    /// Applies the eight performance-tuned `HiGHS` configuration options.
256    ///
257    /// Called once during construction. Returns `Ok(())` if all options are set
258    /// successfully, or `Err(SolverError::InternalError)` with the failing
259    /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
260    fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
261        for opt in &default_options() {
262            // SAFETY: `handle` is a valid, non-null HiGHS pointer.
263            let status = unsafe { opt.apply(handle) };
264            if status == ffi::HIGHS_STATUS_ERROR {
265                return Err(SolverError::InternalError {
266                    message: format!(
267                        "HiGHS configuration failed: {}",
268                        opt.name.to_str().unwrap_or("?")
269                    ),
270                    error_code: Some(status),
271                });
272            }
273        }
274        Ok(())
275    }
276
277    /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
278    /// a [`SolutionView`] borrowing directly from those buffers.
279    ///
280    /// The returned view borrows solver-internal buffers and is valid until the next
281    /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
282    /// canonical sign convention (per Solver Abstraction SS8).
283    fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
284        // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
285        let status = unsafe {
286            ffi::cobre_highs_get_solution(
287                self.handle,
288                self.col_value.as_mut_ptr(),
289                self.col_dual.as_mut_ptr(),
290                self.row_value.as_mut_ptr(),
291                self.row_dual.as_mut_ptr(),
292            )
293        };
294        assert_ne!(
295            status,
296            ffi::HIGHS_STATUS_ERROR,
297            "cobre_highs_get_solution failed after optimal solve"
298        );
299
300        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
301        let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
302
303        // SAFETY: iteration count is non-negative so cast is safe.
304        #[allow(clippy::cast_sign_loss)]
305        let iterations =
306            unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
307
308        SolutionView {
309            objective,
310            primal: &self.col_value[..self.num_cols],
311            dual: &self.row_dual[..self.num_rows],
312            reduced_costs: &self.col_dual[..self.num_cols],
313            iterations,
314            solve_time_seconds,
315        }
316    }
317
318    /// Restores default options after retry escalation.
319    ///
320    /// Errors are silently ignored — already in recovery path.
321    fn restore_default_settings(&mut self) {
322        for opt in &default_options() {
323            // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
324            unsafe { opt.apply(self.handle) };
325        }
326    }
327
328    /// Runs the solver once and returns the raw `HiGHS` model status.
329    fn run_once(&mut self) -> i32 {
330        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
331        let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
332        if run_status == ffi::HIGHS_STATUS_ERROR {
333            return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
334        }
335        // SAFETY: same.
336        unsafe { ffi::cobre_highs_get_model_status(self.handle) }
337    }
338
339    /// Interprets a non-optimal status as a terminal `SolverError`.
340    ///
341    /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
342    /// or `Some(error)` for terminal statuses.
343    fn interpret_terminal_status(
344        &mut self,
345        status: i32,
346        solve_time_seconds: f64,
347    ) -> Option<SolverError> {
348        match status {
349            ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
350                // Caller should have handled optimal before reaching here.
351                None
352            }
353            ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
354            ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
355                // Probe for a dual ray to classify as Infeasible, then a primal
356                // ray to classify as Unbounded. The ray values are not stored in
357                // the error -- only the classification matters.
358                let mut has_dual_ray: i32 = 0;
359                // A scratch buffer is needed for the HiGHS API even though the
360                // values are discarded after classification.
361                let mut dual_buf = vec![0.0_f64; self.num_rows];
362                // SAFETY: valid non-null HiGHS pointer; buffers are valid.
363                let dual_status = unsafe {
364                    ffi::cobre_highs_get_dual_ray(
365                        self.handle,
366                        &raw mut has_dual_ray,
367                        dual_buf.as_mut_ptr(),
368                    )
369                };
370                if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
371                    return Some(SolverError::Infeasible);
372                }
373                let mut has_primal_ray: i32 = 0;
374                let mut primal_buf = vec![0.0_f64; self.num_cols];
375                // SAFETY: valid non-null HiGHS pointer; buffers are valid.
376                let primal_status = unsafe {
377                    ffi::cobre_highs_get_primal_ray(
378                        self.handle,
379                        &raw mut has_primal_ray,
380                        primal_buf.as_mut_ptr(),
381                    )
382                };
383                if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
384                    return Some(SolverError::Unbounded);
385                }
386                Some(SolverError::Infeasible)
387            }
388            ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
389            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
390                elapsed_seconds: solve_time_seconds,
391            }),
392            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
393                // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
394                #[allow(clippy::cast_sign_loss)]
395                let iterations =
396                    unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
397                Some(SolverError::IterationLimit { iterations })
398            }
399            ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
400                // Signal to the caller that retry should continue.
401                None
402            }
403            other => Some(SolverError::InternalError {
404                message: format!("HiGHS returned unexpected model status {other}"),
405                error_code: Some(other),
406            }),
407        }
408    }
409
410    /// Converts `usize` indices to `i32` in the internal scratch buffer.
411    ///
412    /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
413    fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
414        if source.len() > self.scratch_i32.len() {
415            self.scratch_i32.resize(source.len(), 0);
416        }
417        for (i, &v) in source.iter().enumerate() {
418            debug_assert!(
419                i32::try_from(v).is_ok(),
420                "usize index {v} overflows i32::MAX at position {i}"
421            );
422            // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
423            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
424            {
425                self.scratch_i32[i] = v as i32;
426            }
427        }
428        &self.scratch_i32[..source.len()]
429    }
430}
431
432impl Drop for HighsSolver {
433    fn drop(&mut self) {
434        // SAFETY: valid HiGHS pointer from construction, called once per instance.
435        unsafe { ffi::cobre_highs_destroy(self.handle) };
436    }
437}
438
439impl SolverInterface for HighsSolver {
440    fn name(&self) -> &'static str {
441        "HiGHS"
442    }
443
444    fn load_model(&mut self, template: &StageTemplate) {
445        // SAFETY:
446        // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
447        // - All pointer arguments point into owned `Vec` data that remains alive for the
448        //   duration of this call.
449        // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
450        //   template, alive for the duration of this borrow.
451        // - All slice lengths match the HiGHS API contract:
452        //   `num_col + 1` for a_start, `num_nz` for a_index and a_value,
453        //   `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
454        assert!(
455            i32::try_from(template.num_cols).is_ok(),
456            "num_cols {} overflows i32: LP exceeds HiGHS API limit",
457            template.num_cols
458        );
459        assert!(
460            i32::try_from(template.num_rows).is_ok(),
461            "num_rows {} overflows i32: LP exceeds HiGHS API limit",
462            template.num_rows
463        );
464        assert!(
465            i32::try_from(template.num_nz).is_ok(),
466            "num_nz {} overflows i32: LP exceeds HiGHS API limit",
467            template.num_nz
468        );
469        // SAFETY: All three values have been asserted to fit in i32 above.
470        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
471        let num_col = template.num_cols as i32;
472        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
473        let num_row = template.num_rows as i32;
474        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
475        let num_nz = template.num_nz as i32;
476        let status = unsafe {
477            ffi::cobre_highs_pass_lp(
478                self.handle,
479                num_col,
480                num_row,
481                num_nz,
482                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
483                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
484                0.0, // objective offset
485                template.objective.as_ptr(),
486                template.col_lower.as_ptr(),
487                template.col_upper.as_ptr(),
488                template.row_lower.as_ptr(),
489                template.row_upper.as_ptr(),
490                template.col_starts.as_ptr(),
491                template.row_indices.as_ptr(),
492                template.values.as_ptr(),
493            )
494        };
495
496        assert_ne!(
497            status,
498            ffi::HIGHS_STATUS_ERROR,
499            "cobre_highs_pass_lp failed with status {status}"
500        );
501
502        self.num_cols = template.num_cols;
503        self.num_rows = template.num_rows;
504        self.has_model = true;
505
506        // Resize solution extraction buffers to match the new LP dimensions.
507        // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
508        self.col_value.resize(self.num_cols, 0.0);
509        self.col_dual.resize(self.num_cols, 0.0);
510        self.row_value.resize(self.num_rows, 0.0);
511        self.row_dual.resize(self.num_rows, 0.0);
512
513        // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
514        // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
515        self.basis_col_i32.resize(self.num_cols, 0);
516        self.basis_row_i32.resize(self.num_rows, 0);
517    }
518
519    fn add_rows(&mut self, cuts: &RowBatch) {
520        assert!(
521            i32::try_from(cuts.num_rows).is_ok(),
522            "cuts.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
523            cuts.num_rows
524        );
525        assert!(
526            i32::try_from(cuts.col_indices.len()).is_ok(),
527            "cuts nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
528            cuts.col_indices.len()
529        );
530        // SAFETY: Both values have been asserted to fit in i32 above.
531        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
532        let num_new_row = cuts.num_rows as i32;
533        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
534        let num_new_nz = cuts.col_indices.len() as i32;
535
536        // SAFETY:
537        // - `self.handle` is a valid, non-null HiGHS pointer.
538        // - All pointer arguments point into owned data alive for the duration of this call.
539        // - `cuts.row_starts` and `cuts.col_indices` are `Vec<i32>` owned by the RowBatch,
540        //   alive for the duration of this borrow.
541        // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
542        //   `num_rows` for lower/upper bounds.
543        let status = unsafe {
544            ffi::cobre_highs_add_rows(
545                self.handle,
546                num_new_row,
547                cuts.row_lower.as_ptr(),
548                cuts.row_upper.as_ptr(),
549                num_new_nz,
550                cuts.row_starts.as_ptr(),
551                cuts.col_indices.as_ptr(),
552                cuts.values.as_ptr(),
553            )
554        };
555
556        assert_ne!(
557            status,
558            ffi::HIGHS_STATUS_ERROR,
559            "cobre_highs_add_rows failed with status {status}"
560        );
561
562        self.num_rows += cuts.num_rows;
563
564        // Grow row-indexed solution extraction buffers to cover the new rows.
565        self.row_value.resize(self.num_rows, 0.0);
566        self.row_dual.resize(self.num_rows, 0.0);
567
568        // Grow basis row i32 buffer to cover the new rows.
569        self.basis_row_i32.resize(self.num_rows, 0);
570    }
571
572    fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
573        assert!(
574            indices.len() == lower.len() && indices.len() == upper.len(),
575            "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
576            indices.len(),
577            lower.len(),
578            upper.len()
579        );
580        if indices.is_empty() {
581            return;
582        }
583
584        assert!(
585            i32::try_from(indices.len()).is_ok(),
586            "set_row_bounds: indices.len() {} overflows i32",
587            indices.len()
588        );
589        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
590        let num_entries = indices.len() as i32;
591
592        // SAFETY:
593        // - `self.handle` is a valid, non-null HiGHS pointer.
594        // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
595        //   alive for `'self`. Pointer is used immediately in the FFI call.
596        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
597        // - `num_entries` equals the lengths of all three arrays.
598        let status = unsafe {
599            ffi::cobre_highs_change_rows_bounds_by_set(
600                self.handle,
601                num_entries,
602                self.convert_to_i32_scratch(indices).as_ptr(),
603                lower.as_ptr(),
604                upper.as_ptr(),
605            )
606        };
607
608        assert_ne!(
609            status,
610            ffi::HIGHS_STATUS_ERROR,
611            "cobre_highs_change_rows_bounds_by_set failed with status {status}"
612        );
613    }
614
615    fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
616        assert!(
617            indices.len() == lower.len() && indices.len() == upper.len(),
618            "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
619            indices.len(),
620            lower.len(),
621            upper.len()
622        );
623        if indices.is_empty() {
624            return;
625        }
626
627        assert!(
628            i32::try_from(indices.len()).is_ok(),
629            "set_col_bounds: indices.len() {} overflows i32",
630            indices.len()
631        );
632        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
633        let num_entries = indices.len() as i32;
634
635        // SAFETY:
636        // - `self.handle` is a valid, non-null HiGHS pointer.
637        // - Converted indices point into `self.scratch_i32`, alive for `'self`.
638        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
639        // - `num_entries` equals the lengths of all three arrays.
640        let status = unsafe {
641            ffi::cobre_highs_change_cols_bounds_by_set(
642                self.handle,
643                num_entries,
644                self.convert_to_i32_scratch(indices).as_ptr(),
645                lower.as_ptr(),
646                upper.as_ptr(),
647            )
648        };
649
650        assert_ne!(
651            status,
652            ffi::HIGHS_STATUS_ERROR,
653            "cobre_highs_change_cols_bounds_by_set failed with status {status}"
654        );
655    }
656
657    #[allow(clippy::too_many_lines)]
658    fn solve(&mut self) -> Result<SolutionView<'_>, SolverError> {
659        assert!(
660            self.has_model,
661            "solve called without a loaded model — call load_model first"
662        );
663        let t0 = Instant::now();
664        let model_status = self.run_once();
665        let solve_time = t0.elapsed().as_secs_f64();
666
667        self.stats.solve_count += 1;
668
669        if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
670            // Read iteration count from FFI BEFORE establishing the shared borrow
671            // via extract_solution_view, so stats can be updated without violating
672            // the aliasing rules.
673            // SAFETY: handle is valid non-null HiGHS pointer.
674            #[allow(clippy::cast_sign_loss)]
675            let iterations =
676                unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
677            self.stats.success_count += 1;
678            self.stats.first_try_successes += 1;
679            self.stats.total_iterations += iterations;
680            self.stats.total_solve_time_seconds += solve_time;
681            return Ok(self.extract_solution_view(solve_time));
682        }
683
684        // Check for a definitive terminal status (not a retry-able error).
685        // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
686        // on numerically difficult LPs with wide coefficient ranges. The retry
687        // escalation (especially presolve in the core sequence) often resolves these.
688        let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
689        if !is_unbounded {
690            if let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time) {
691                self.stats.failure_count += 1;
692                return Err(terminal_err);
693            }
694        }
695
696        // 12-level retry escalation (HiGHS Implementation SS3). Organised into
697        // two phases:
698        //
699        // Phase 1 (levels 0-4): Core cumulative sequence. Each level adds one
700        //   option on top of the previous state. This proven sequence resolves
701        //   the vast majority of retry-recoverable failures.
702        //   L0: cold restart
703        //   L1: + presolve
704        //   L2: + dual simplex
705        //   L3: + relaxed tolerances 1e-6
706        //   L4: + IPM
707        //
708        // Phase 2 (levels 5-11): Extended strategies. Each level starts from
709        //   a clean default state with presolve enabled and a time cap, then
710        //   applies a specific combination of scaling, tolerances, and solver
711        //   type. These address LPs with extreme coefficient ranges that the
712        //   core sequence cannot resolve.
713        let retry_time_limit = 30.0_f64;
714        let num_retry_levels = 12_u32;
715
716        let mut retry_attempts: u64 = 0;
717        let mut terminal_err: Option<SolverError> = None;
718        let mut found_optimal = false;
719        let mut optimal_time = 0.0_f64;
720        let mut optimal_iterations: u64 = 0;
721
722        for level in 0..num_retry_levels {
723            // SAFETY: handle is valid non-null HiGHS pointer; option names/values
724            // are static C strings; no retained pointers after call.
725            match level {
726                // -- Phase 1: Core cumulative sequence (levels 0-4) -----------
727                //
728                // Level 0: cold restart (clear solver state), primal simplex.
729                0 => {
730                    unsafe { ffi::cobre_highs_clear_solver(self.handle) };
731                }
732                // Level 1: + presolve.
733                1 => unsafe {
734                    ffi::cobre_highs_set_string_option(
735                        self.handle,
736                        c"presolve".as_ptr(),
737                        c"on".as_ptr(),
738                    );
739                },
740                // Level 2: + dual simplex.
741                // Cumulative: presolve + dual simplex.
742                2 => unsafe {
743                    ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
744                },
745                // Level 3: + relaxed tolerances 1e-6.
746                // Cumulative: presolve + dual simplex + relaxed tolerances.
747                3 => unsafe {
748                    ffi::cobre_highs_set_double_option(
749                        self.handle,
750                        c"primal_feasibility_tolerance".as_ptr(),
751                        1e-6,
752                    );
753                    ffi::cobre_highs_set_double_option(
754                        self.handle,
755                        c"dual_feasibility_tolerance".as_ptr(),
756                        1e-6,
757                    );
758                },
759                // Level 4: + IPM.
760                // Cumulative: presolve + relaxed tolerances + IPM.
761                4 => unsafe {
762                    ffi::cobre_highs_set_string_option(
763                        self.handle,
764                        c"solver".as_ptr(),
765                        c"ipm".as_ptr(),
766                    );
767                },
768
769                // -- Phase 2: Extended strategies (levels 5-11) ---------------
770                // Each level starts from a clean default state with presolve
771                // and a time cap, then applies specific options.
772                //
773                // Level 5: presolve + primal + forced equilibration scaling.
774                5 => {
775                    self.restore_default_settings();
776                    unsafe {
777                        ffi::cobre_highs_set_string_option(
778                            self.handle,
779                            c"presolve".as_ptr(),
780                            c"on".as_ptr(),
781                        );
782                        ffi::cobre_highs_set_double_option(
783                            self.handle,
784                            c"time_limit".as_ptr(),
785                            retry_time_limit,
786                        );
787                        ffi::cobre_highs_set_int_option(
788                            self.handle,
789                            c"simplex_scale_strategy".as_ptr(),
790                            3, // forced equilibration
791                        );
792                    }
793                }
794                // Level 6: presolve + dual + max-value scaling.
795                6 => {
796                    self.restore_default_settings();
797                    unsafe {
798                        ffi::cobre_highs_set_string_option(
799                            self.handle,
800                            c"presolve".as_ptr(),
801                            c"on".as_ptr(),
802                        );
803                        ffi::cobre_highs_set_double_option(
804                            self.handle,
805                            c"time_limit".as_ptr(),
806                            retry_time_limit,
807                        );
808                        ffi::cobre_highs_set_int_option(
809                            self.handle,
810                            c"simplex_strategy".as_ptr(),
811                            1,
812                        );
813                        ffi::cobre_highs_set_int_option(
814                            self.handle,
815                            c"simplex_scale_strategy".as_ptr(),
816                            4, // max value scaling
817                        );
818                    }
819                }
820                // Level 7: presolve + primal + relaxed tol + forced equil.
821                7 => {
822                    self.restore_default_settings();
823                    unsafe {
824                        ffi::cobre_highs_set_string_option(
825                            self.handle,
826                            c"presolve".as_ptr(),
827                            c"on".as_ptr(),
828                        );
829                        ffi::cobre_highs_set_double_option(
830                            self.handle,
831                            c"time_limit".as_ptr(),
832                            retry_time_limit,
833                        );
834                        ffi::cobre_highs_set_int_option(
835                            self.handle,
836                            c"simplex_scale_strategy".as_ptr(),
837                            3,
838                        );
839                        ffi::cobre_highs_set_double_option(
840                            self.handle,
841                            c"primal_feasibility_tolerance".as_ptr(),
842                            1e-6,
843                        );
844                        ffi::cobre_highs_set_double_option(
845                            self.handle,
846                            c"dual_feasibility_tolerance".as_ptr(),
847                            1e-6,
848                        );
849                    }
850                }
851                // Level 8: presolve + user objective scaling (2^-10).
852                8 => {
853                    self.restore_default_settings();
854                    unsafe {
855                        ffi::cobre_highs_set_string_option(
856                            self.handle,
857                            c"presolve".as_ptr(),
858                            c"on".as_ptr(),
859                        );
860                        ffi::cobre_highs_set_double_option(
861                            self.handle,
862                            c"time_limit".as_ptr(),
863                            retry_time_limit,
864                        );
865                        ffi::cobre_highs_set_int_option(
866                            self.handle,
867                            c"user_objective_scale".as_ptr(),
868                            -10,
869                        );
870                    }
871                }
872                // Level 9: presolve + dual + user objective + bound scaling.
873                9 => {
874                    self.restore_default_settings();
875                    unsafe {
876                        ffi::cobre_highs_set_string_option(
877                            self.handle,
878                            c"presolve".as_ptr(),
879                            c"on".as_ptr(),
880                        );
881                        ffi::cobre_highs_set_double_option(
882                            self.handle,
883                            c"time_limit".as_ptr(),
884                            retry_time_limit,
885                        );
886                        ffi::cobre_highs_set_int_option(
887                            self.handle,
888                            c"simplex_strategy".as_ptr(),
889                            1,
890                        );
891                        ffi::cobre_highs_set_int_option(
892                            self.handle,
893                            c"user_objective_scale".as_ptr(),
894                            -10,
895                        );
896                        ffi::cobre_highs_set_int_option(
897                            self.handle,
898                            c"user_bound_scale".as_ptr(),
899                            -5,
900                        );
901                    }
902                }
903                // Level 10: presolve + aggressive user scaling + relaxed tol.
904                10 => {
905                    self.restore_default_settings();
906                    unsafe {
907                        ffi::cobre_highs_set_string_option(
908                            self.handle,
909                            c"presolve".as_ptr(),
910                            c"on".as_ptr(),
911                        );
912                        ffi::cobre_highs_set_double_option(
913                            self.handle,
914                            c"time_limit".as_ptr(),
915                            retry_time_limit,
916                        );
917                        ffi::cobre_highs_set_int_option(
918                            self.handle,
919                            c"user_objective_scale".as_ptr(),
920                            -13,
921                        );
922                        ffi::cobre_highs_set_int_option(
923                            self.handle,
924                            c"user_bound_scale".as_ptr(),
925                            -8,
926                        );
927                        ffi::cobre_highs_set_double_option(
928                            self.handle,
929                            c"primal_feasibility_tolerance".as_ptr(),
930                            1e-6,
931                        );
932                        ffi::cobre_highs_set_double_option(
933                            self.handle,
934                            c"dual_feasibility_tolerance".as_ptr(),
935                            1e-6,
936                        );
937                    }
938                }
939                // Level 11: IPM + presolve + user scaling + relaxed tol.
940                // Last resort: full stability toolkit.
941                11 => {
942                    self.restore_default_settings();
943                    unsafe {
944                        ffi::cobre_highs_set_string_option(
945                            self.handle,
946                            c"solver".as_ptr(),
947                            c"ipm".as_ptr(),
948                        );
949                        ffi::cobre_highs_set_string_option(
950                            self.handle,
951                            c"presolve".as_ptr(),
952                            c"on".as_ptr(),
953                        );
954                        ffi::cobre_highs_set_double_option(
955                            self.handle,
956                            c"time_limit".as_ptr(),
957                            retry_time_limit,
958                        );
959                        ffi::cobre_highs_set_int_option(
960                            self.handle,
961                            c"user_objective_scale".as_ptr(),
962                            -10,
963                        );
964                        ffi::cobre_highs_set_int_option(
965                            self.handle,
966                            c"user_bound_scale".as_ptr(),
967                            -5,
968                        );
969                        ffi::cobre_highs_set_double_option(
970                            self.handle,
971                            c"primal_feasibility_tolerance".as_ptr(),
972                            1e-6,
973                        );
974                        ffi::cobre_highs_set_double_option(
975                            self.handle,
976                            c"dual_feasibility_tolerance".as_ptr(),
977                            1e-6,
978                        );
979                    }
980                }
981                _ => unreachable!(),
982            }
983
984            retry_attempts += 1;
985
986            let t_retry = Instant::now();
987            let retry_status = self.run_once();
988            let retry_time = t_retry.elapsed().as_secs_f64();
989
990            if retry_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
991                // Capture stats before establishing the borrow.
992                // SAFETY: handle is valid non-null HiGHS pointer.
993                #[allow(clippy::cast_sign_loss)]
994                let iters =
995                    unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
996                found_optimal = true;
997                optimal_time = retry_time;
998                optimal_iterations = iters;
999                break;
1000            }
1001
1002            // UNBOUNDED and TIME_LIMIT during retry continue to the next level:
1003            // UNBOUNDED may be spurious (presolve resolves it); TIME_LIMIT means
1004            // this strategy is too slow but another may converge faster.
1005            // Other terminal statuses (INFEASIBLE, ITERATION_LIMIT) stop immediately.
1006            let retryable = retry_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED
1007                || retry_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT;
1008            if !retryable {
1009                if let Some(e) = self.interpret_terminal_status(retry_status, retry_time) {
1010                    terminal_err = Some(e);
1011                    break;
1012                }
1013            }
1014            // Still SOLVE_ERROR, UNKNOWN, UNBOUNDED, or TIME_LIMIT -- continue.
1015        }
1016
1017        // Restore default settings unconditionally (regardless of retry outcome).
1018        // `restore_default_settings()` covers all 8 defaults. Only retry-only
1019        // options need explicit reset.
1020        self.restore_default_settings();
1021        unsafe {
1022            ffi::cobre_highs_set_double_option(self.handle, c"time_limit".as_ptr(), f64::INFINITY);
1023            ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), 0);
1024            ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), 0);
1025        }
1026
1027        // Update statistics with accumulated retry attempts.
1028        self.stats.retry_count += retry_attempts;
1029
1030        if found_optimal {
1031            self.stats.success_count += 1;
1032            self.stats.total_iterations += optimal_iterations;
1033            self.stats.total_solve_time_seconds += optimal_time;
1034            return Ok(self.extract_solution_view(optimal_time));
1035        }
1036
1037        self.stats.failure_count += 1;
1038        Err(terminal_err.unwrap_or_else(|| {
1039            // All 12 retry levels exhausted without a definitive result.
1040            if is_unbounded {
1041                SolverError::Unbounded
1042            } else {
1043                SolverError::NumericalDifficulty {
1044                    message: "HiGHS failed to reach optimality after all retry escalation levels"
1045                        .to_string(),
1046                }
1047            }
1048        }))
1049    }
1050
1051    fn reset(&mut self) {
1052        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer. `cobre_highs_clear_solver`
1053        // discards the cached basis and factorization. HiGHS preserves the model data
1054        // internally, but Cobre's `reset` contract requires `load_model` before the
1055        // next solve — enforced by setting `has_model = false`.
1056        let status = unsafe { ffi::cobre_highs_clear_solver(self.handle) };
1057        debug_assert_ne!(
1058            status,
1059            ffi::HIGHS_STATUS_ERROR,
1060            "cobre_highs_clear_solver failed — HiGHS internal state may be inconsistent"
1061        );
1062        // Force `load_model` to be called before the next solve.
1063        self.num_cols = 0;
1064        self.num_rows = 0;
1065        self.has_model = false;
1066        // Intentionally do NOT zero `self.stats` -- statistics accumulate for the
1067        // lifetime of the instance (per trait contract, SS4.3).
1068    }
1069
1070    fn get_basis(&mut self, out: &mut crate::types::Basis) {
1071        assert!(
1072            self.has_model,
1073            "get_basis called without a loaded model — call load_model first"
1074        );
1075
1076        out.col_status.resize(self.num_cols, 0);
1077        out.row_status.resize(self.num_rows, 0);
1078
1079        // SAFETY:
1080        // - `self.handle` is a valid, non-null HiGHS pointer.
1081        // - `out.col_status` has been resized to `num_cols` entries above.
1082        // - `out.row_status` has been resized to `num_rows` entries above.
1083        // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
1084        let get_status = unsafe {
1085            ffi::cobre_highs_get_basis(
1086                self.handle,
1087                out.col_status.as_mut_ptr(),
1088                out.row_status.as_mut_ptr(),
1089            )
1090        };
1091
1092        assert_ne!(
1093            get_status,
1094            ffi::HIGHS_STATUS_ERROR,
1095            "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
1096        );
1097    }
1098
1099    fn solve_with_basis(
1100        &mut self,
1101        basis: &crate::types::Basis,
1102    ) -> Result<crate::types::SolutionView<'_>, SolverError> {
1103        assert!(
1104            self.has_model,
1105            "solve_with_basis called without a loaded model — call load_model first"
1106        );
1107        assert!(
1108            basis.col_status.len() == self.num_cols,
1109            "basis column count {} does not match LP column count {}",
1110            basis.col_status.len(),
1111            self.num_cols
1112        );
1113
1114        // Track every call as a basis offer for diagnostics.
1115        self.stats.basis_offered += 1;
1116
1117        // Copy raw i32 codes directly into the pre-allocated buffers — no enum
1118        // translation. Zero-copy warm-start path.
1119        self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
1120
1121        // Handle dimension mismatch for dynamic cuts:
1122        // - Fewer rows than LP: extend with BASIC.
1123        // - More rows than LP: truncate (extra entries ignored).
1124        let basis_rows = basis.row_status.len();
1125        let lp_rows = self.num_rows;
1126        let copy_len = basis_rows.min(lp_rows);
1127        self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
1128        if lp_rows > basis_rows {
1129            self.basis_row_i32[basis_rows..lp_rows].fill(ffi::HIGHS_BASIS_STATUS_BASIC);
1130        }
1131
1132        // Attempt to install the basis in HiGHS.
1133        // SAFETY:
1134        // - `self.handle` is a valid, non-null HiGHS pointer.
1135        // - `basis_col_i32` has been sized to at least `num_cols` in `load_model`.
1136        // - `basis_row_i32` has been sized to at least `num_rows` in `load_model`/`add_rows`.
1137        // - We pass exactly `num_cols` col entries and `num_rows` row entries.
1138        let set_status = unsafe {
1139            ffi::cobre_highs_set_basis(
1140                self.handle,
1141                self.basis_col_i32.as_ptr(),
1142                self.basis_row_i32.as_ptr(),
1143            )
1144        };
1145
1146        // Basis rejection tracking: fall back to cold-start and track for diagnostics.
1147        if set_status == ffi::HIGHS_STATUS_ERROR {
1148            self.stats.basis_rejections += 1;
1149            debug_assert!(false, "raw basis rejected; falling back to cold-start");
1150        }
1151
1152        // Delegate to solve() which handles retry escalation and statistics updates.
1153        self.solve()
1154    }
1155
1156    fn statistics(&self) -> SolverStatistics {
1157        self.stats.clone()
1158    }
1159}
1160
1161/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1162///
1163/// Gated behind the `test-support` feature. The raw handle is intentionally not
1164/// part of the public API — callers use these methods to configure time/iteration
1165/// limits before a solve without going through the safe wrapper.
1166#[cfg(feature = "test-support")]
1167impl HighsSolver {
1168    /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1169    ///
1170    /// # Safety
1171    ///
1172    /// The returned pointer is valid for the lifetime of `self`. The caller must
1173    /// not store the pointer beyond that lifetime, must not call
1174    /// `cobre_highs_destroy` on it, and must not alias it across threads.
1175    #[must_use]
1176    pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1177        self.handle
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::HighsSolver;
1184    use crate::{
1185        SolverInterface,
1186        types::{Basis, RowBatch, StageTemplate},
1187    };
1188
1189    // Shared LP fixture from Solver Interface Testing SS1.1:
1190    // 3 variables, 2 structural constraints, 3 non-zeros.
1191    //
1192    //   min  0*x0 + 1*x1 + 50*x2
1193    //   s.t. x0            = 6   (state-fixing)
1194    //        2*x0 + x2     = 14  (power balance)
1195    //   x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1196    //
1197    // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1198    //   col_starts  = [0, 2, 2, 3]
1199    //   row_indices = [0, 1, 1]
1200    //   values      = [1.0, 2.0, 1.0]
1201    fn make_fixture_stage_template() -> StageTemplate {
1202        StageTemplate {
1203            num_cols: 3,
1204            num_rows: 2,
1205            num_nz: 3,
1206            col_starts: vec![0_i32, 2, 2, 3],
1207            row_indices: vec![0_i32, 1, 1],
1208            values: vec![1.0, 2.0, 1.0],
1209            col_lower: vec![0.0, 0.0, 0.0],
1210            col_upper: vec![10.0, f64::INFINITY, 8.0],
1211            objective: vec![0.0, 1.0, 50.0],
1212            row_lower: vec![6.0, 14.0],
1213            row_upper: vec![6.0, 14.0],
1214            n_state: 1,
1215            n_transfer: 0,
1216            n_dual_relevant: 1,
1217            n_hydro: 1,
1218            max_par_order: 0,
1219            col_scale: Vec::new(),
1220            row_scale: Vec::new(),
1221        }
1222    }
1223
1224    // Benders cut fixture from Solver Interface Testing SS1.2:
1225    // Cut 1: -5*x0 + x1 >= 20  (col_indices [0,1], values [-5, 1])
1226    // Cut 2:  3*x0 + x1 >= 80  (col_indices [0,1], values [ 3, 1])
1227    fn make_fixture_row_batch() -> RowBatch {
1228        RowBatch {
1229            num_rows: 2,
1230            row_starts: vec![0_i32, 2, 4],
1231            col_indices: vec![0_i32, 1, 0, 1],
1232            values: vec![-5.0, 1.0, 3.0, 1.0],
1233            row_lower: vec![20.0, 80.0],
1234            row_upper: vec![f64::INFINITY, f64::INFINITY],
1235        }
1236    }
1237
1238    #[test]
1239    fn test_highs_solver_create_and_name() {
1240        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1241        assert_eq!(solver.name(), "HiGHS");
1242        // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1243    }
1244
1245    #[test]
1246    fn test_highs_solver_send_bound() {
1247        fn assert_send<T: Send>() {}
1248        assert_send::<HighsSolver>();
1249    }
1250
1251    #[test]
1252    fn test_highs_solver_statistics_initial() {
1253        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1254        let stats = solver.statistics();
1255        assert_eq!(stats.solve_count, 0);
1256        assert_eq!(stats.success_count, 0);
1257        assert_eq!(stats.failure_count, 0);
1258        assert_eq!(stats.total_iterations, 0);
1259        assert_eq!(stats.retry_count, 0);
1260        assert_eq!(stats.total_solve_time_seconds, 0.0);
1261    }
1262
1263    #[test]
1264    fn test_highs_load_model_updates_dimensions() {
1265        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1266        let template = make_fixture_stage_template();
1267
1268        solver.load_model(&template);
1269
1270        assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1271        assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1272        assert_eq!(
1273            solver.col_value.len(),
1274            3,
1275            "col_value buffer must be resized to num_cols"
1276        );
1277        assert_eq!(
1278            solver.col_dual.len(),
1279            3,
1280            "col_dual buffer must be resized to num_cols"
1281        );
1282        assert_eq!(
1283            solver.row_value.len(),
1284            2,
1285            "row_value buffer must be resized to num_rows"
1286        );
1287        assert_eq!(
1288            solver.row_dual.len(),
1289            2,
1290            "row_dual buffer must be resized to num_rows"
1291        );
1292    }
1293
1294    #[test]
1295    fn test_highs_add_rows_updates_dimensions() {
1296        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1297        let template = make_fixture_stage_template();
1298        let cuts = make_fixture_row_batch();
1299
1300        solver.load_model(&template);
1301        solver.add_rows(&cuts);
1302
1303        // 2 structural rows + 2 cut rows = 4
1304        assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1305        assert_eq!(
1306            solver.row_dual.len(),
1307            4,
1308            "row_dual buffer must be resized to 4 after add_rows"
1309        );
1310        assert_eq!(
1311            solver.row_value.len(),
1312            4,
1313            "row_value buffer must be resized to 4 after add_rows"
1314        );
1315        // Columns unchanged
1316        assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1317    }
1318
1319    #[test]
1320    fn test_highs_set_row_bounds_no_panic() {
1321        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1322        let template = make_fixture_stage_template();
1323        solver.load_model(&template);
1324
1325        // Patch row 0 to equality at 4.0. Must complete without panic.
1326        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1327    }
1328
1329    #[test]
1330    fn test_highs_set_col_bounds_no_panic() {
1331        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1332        let template = make_fixture_stage_template();
1333        solver.load_model(&template);
1334
1335        // Patch column 1 lower bound to 10.0. Must complete without panic.
1336        solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1337    }
1338
1339    #[test]
1340    fn test_highs_set_bounds_empty_no_panic() {
1341        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1342        let template = make_fixture_stage_template();
1343        solver.load_model(&template);
1344
1345        // Empty patch slices should be short-circuited without any FFI call.
1346        solver.set_row_bounds(&[], &[], &[]);
1347        solver.set_col_bounds(&[], &[], &[]);
1348    }
1349
1350    /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1351    /// Optimal: x0=6, x1=0, x2=2, objective=100.
1352    #[test]
1353    fn test_highs_solve_basic_lp() {
1354        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1355        let template = make_fixture_stage_template();
1356        solver.load_model(&template);
1357
1358        let solution = solver
1359            .solve()
1360            .expect("solve() must succeed on a feasible LP");
1361
1362        assert!(
1363            (solution.objective - 100.0).abs() < 1e-8,
1364            "objective must be 100.0, got {}",
1365            solution.objective
1366        );
1367        assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1368        assert!(
1369            (solution.primal[0] - 6.0).abs() < 1e-8,
1370            "primal[0] (x0) must be 6.0, got {}",
1371            solution.primal[0]
1372        );
1373        assert!(
1374            (solution.primal[1] - 0.0).abs() < 1e-8,
1375            "primal[1] (x1) must be 0.0, got {}",
1376            solution.primal[1]
1377        );
1378        assert!(
1379            (solution.primal[2] - 2.0).abs() < 1e-8,
1380            "primal[2] (x2) must be 2.0, got {}",
1381            solution.primal[2]
1382        );
1383    }
1384
1385    /// SS1.2: after adding two Benders cuts to SS1.1, optimal objective = 162.
1386    /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1387    /// Obj = 0*6 + 1*62 + 50*2 = 162.
1388    #[test]
1389    fn test_highs_solve_with_cuts() {
1390        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1391        let template = make_fixture_stage_template();
1392        let cuts = make_fixture_row_batch();
1393        solver.load_model(&template);
1394        solver.add_rows(&cuts);
1395
1396        let solution = solver
1397            .solve()
1398            .expect("solve() must succeed on a feasible LP with cuts");
1399
1400        assert!(
1401            (solution.objective - 162.0).abs() < 1e-8,
1402            "objective must be 162.0, got {}",
1403            solution.objective
1404        );
1405        assert!(
1406            (solution.primal[0] - 6.0).abs() < 1e-8,
1407            "primal[0] must be 6.0, got {}",
1408            solution.primal[0]
1409        );
1410        assert!(
1411            (solution.primal[1] - 62.0).abs() < 1e-8,
1412            "primal[1] must be 62.0, got {}",
1413            solution.primal[1]
1414        );
1415        assert!(
1416            (solution.primal[2] - 2.0).abs() < 1e-8,
1417            "primal[2] must be 2.0, got {}",
1418            solution.primal[2]
1419        );
1420    }
1421
1422    /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1423    /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1424    #[test]
1425    fn test_highs_solve_after_rhs_patch() {
1426        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1427        let template = make_fixture_stage_template();
1428        let cuts = make_fixture_row_batch();
1429        solver.load_model(&template);
1430        solver.add_rows(&cuts);
1431
1432        // Patch row 0 (x0=6 equality) to x0=4.
1433        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1434
1435        let solution = solver
1436            .solve()
1437            .expect("solve() must succeed after RHS patch");
1438
1439        assert!(
1440            (solution.objective - 368.0).abs() < 1e-8,
1441            "objective must be 368.0, got {}",
1442            solution.objective
1443        );
1444    }
1445
1446    /// After two successful solves, statistics must reflect both.
1447    #[test]
1448    fn test_highs_solve_statistics_increment() {
1449        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1450        let template = make_fixture_stage_template();
1451        solver.load_model(&template);
1452
1453        solver.solve().expect("first solve must succeed");
1454        solver.solve().expect("second solve must succeed");
1455
1456        let stats = solver.statistics();
1457        assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1458        assert_eq!(stats.success_count, 2, "success_count must be 2");
1459        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1460        assert!(
1461            stats.total_iterations > 0,
1462            "total_iterations must be positive"
1463        );
1464    }
1465
1466    /// After `reset()`, statistics counters must be unchanged.
1467    #[test]
1468    fn test_highs_reset_preserves_stats() {
1469        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1470        let template = make_fixture_stage_template();
1471        solver.load_model(&template);
1472        solver.solve().expect("solve must succeed");
1473
1474        let stats_before = solver.statistics();
1475        assert_eq!(
1476            stats_before.solve_count, 1,
1477            "solve_count must be 1 before reset"
1478        );
1479
1480        solver.reset();
1481
1482        let stats_after = solver.statistics();
1483        assert_eq!(
1484            stats_after.solve_count, stats_before.solve_count,
1485            "solve_count must be unchanged after reset"
1486        );
1487        assert_eq!(
1488            stats_after.success_count, stats_before.success_count,
1489            "success_count must be unchanged after reset"
1490        );
1491        assert_eq!(
1492            stats_after.total_iterations, stats_before.total_iterations,
1493            "total_iterations must be unchanged after reset"
1494        );
1495    }
1496
1497    /// The first solve must report a positive iteration count.
1498    #[test]
1499    fn test_highs_solve_iterations_positive() {
1500        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1501        let template = make_fixture_stage_template();
1502        solver.load_model(&template);
1503
1504        let solution = solver.solve().expect("solve must succeed");
1505        assert!(
1506            solution.iterations > 0,
1507            "iterations must be positive, got {}",
1508            solution.iterations
1509        );
1510    }
1511
1512    /// The first solve must report a positive wall-clock time.
1513    #[test]
1514    fn test_highs_solve_time_positive() {
1515        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1516        let template = make_fixture_stage_template();
1517        solver.load_model(&template);
1518
1519        let solution = solver.solve().expect("solve must succeed");
1520        assert!(
1521            solution.solve_time_seconds > 0.0,
1522            "solve_time_seconds must be positive, got {}",
1523            solution.solve_time_seconds
1524        );
1525    }
1526
1527    /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1528    /// `failure_count==0`, and `total_iterations` > 0.
1529    #[test]
1530    fn test_highs_solve_statistics_single() {
1531        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1532        let template = make_fixture_stage_template();
1533        solver.load_model(&template);
1534
1535        solver.solve().expect("solve must succeed");
1536
1537        let stats = solver.statistics();
1538        assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1539        assert_eq!(stats.success_count, 1, "success_count must be 1");
1540        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1541        assert!(
1542            stats.total_iterations > 0,
1543            "total_iterations must be positive after a successful solve"
1544        );
1545    }
1546
1547    /// After `load_model` + `solve()`, `get_basis` must return i32 codes
1548    /// that are all valid `HiGHS` basis status values (0..=4).
1549    #[test]
1550    fn test_get_basis_valid_status_codes() {
1551        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1552        let template = make_fixture_stage_template();
1553        solver.load_model(&template);
1554        solver.solve().expect("solve must succeed before get_basis");
1555
1556        let mut basis = Basis::new(0, 0);
1557        solver.get_basis(&mut basis);
1558
1559        for &code in &basis.col_status {
1560            assert!(
1561                (0..=4).contains(&code),
1562                "col_status code {code} is outside valid HiGHS range 0..=4"
1563            );
1564        }
1565        for &code in &basis.row_status {
1566            assert!(
1567                (0..=4).contains(&code),
1568                "row_status code {code} is outside valid HiGHS range 0..=4"
1569            );
1570        }
1571    }
1572
1573    /// Starting from an empty `Basis`, `get_basis` must resize the output
1574    /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
1575    #[test]
1576    fn test_get_basis_resizes_output() {
1577        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1578        let template = make_fixture_stage_template();
1579        solver.load_model(&template);
1580        solver.solve().expect("solve must succeed before get_basis");
1581
1582        let mut basis = Basis::new(0, 0);
1583        assert_eq!(
1584            basis.col_status.len(),
1585            0,
1586            "initial col_status must be empty"
1587        );
1588        assert_eq!(
1589            basis.row_status.len(),
1590            0,
1591            "initial row_status must be empty"
1592        );
1593
1594        solver.get_basis(&mut basis);
1595
1596        assert_eq!(
1597            basis.col_status.len(),
1598            3,
1599            "col_status must be resized to 3 (num_cols of SS1.1)"
1600        );
1601        assert_eq!(
1602            basis.row_status.len(),
1603            2,
1604            "row_status must be resized to 2 (num_rows of SS1.1)"
1605        );
1606    }
1607
1608    /// Warm-start via `solve_with_basis` on the same LP must reproduce
1609    /// the optimal objective and complete in at most 1 simplex iteration.
1610    #[test]
1611    fn test_solve_with_basis_warm_start() {
1612        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1613        let template = make_fixture_stage_template();
1614        solver.load_model(&template);
1615        solver.solve().expect("cold-start solve must succeed");
1616
1617        let mut basis = Basis::new(0, 0);
1618        solver.get_basis(&mut basis);
1619
1620        // Reload the same model to reset HiGHS internal state.
1621        solver.load_model(&template);
1622        let result = solver
1623            .solve_with_basis(&basis)
1624            .expect("warm-start solve must succeed");
1625
1626        assert!(
1627            (result.objective - 100.0).abs() < 1e-8,
1628            "warm-start objective must be 100.0, got {}",
1629            result.objective
1630        );
1631        assert!(
1632            result.iterations <= 1,
1633            "warm-start from exact basis must use at most 1 iteration, got {}",
1634            result.iterations
1635        );
1636
1637        let stats = solver.statistics();
1638        assert_eq!(
1639            stats.basis_rejections, 0,
1640            "basis_rejections must be 0 when raw basis is accepted, got {}",
1641            stats.basis_rejections
1642        );
1643    }
1644
1645    /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
1646    /// `solve_with_basis` must extend missing rows as Basic and solve correctly.
1647    /// SS1.2 objective with both cuts active is 162.0.
1648    #[test]
1649    fn test_solve_with_basis_dimension_mismatch() {
1650        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1651        let template = make_fixture_stage_template();
1652        let cuts = make_fixture_row_batch();
1653
1654        // First solve on 2-row LP to capture a 2-row basis.
1655        solver.load_model(&template);
1656        solver.solve().expect("SS1.1 solve must succeed");
1657        let mut basis = Basis::new(0, 0);
1658        solver.get_basis(&mut basis);
1659        assert_eq!(
1660            basis.row_status.len(),
1661            2,
1662            "captured basis must have 2 row statuses"
1663        );
1664
1665        // Reload model and add 2 cuts to get a 4-row LP.
1666        solver.load_model(&template);
1667        solver.add_rows(&cuts);
1668        assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
1669
1670        // Warm-start with the 2-row basis; extra rows are extended as Basic.
1671        let result = solver
1672            .solve_with_basis(&basis)
1673            .expect("solve with dimension-mismatched basis must succeed");
1674
1675        assert!(
1676            (result.objective - 162.0).abs() < 1e-8,
1677            "objective with both cuts active must be 162.0, got {}",
1678            result.objective
1679        );
1680    }
1681}
1682
1683// ─── Research verification tests for non-optimal HiGHS model statuses ────
1684//
1685// These tests verify LP formulations that reliably trigger non-optimal
1686// HiGHS model statuses. They use the raw FFI layer to set options not
1687// exposed through SolverInterface and confirm the expected model status.
1688// Findings are documented in:
1689//   plans/phase-3-solver/epic-08-coverage/research-edge-case-lps.md
1690//
1691// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
1692// heuristic solves it without entering the simplex loop, so time/iteration
1693// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
1694#[cfg(test)]
1695#[allow(clippy::doc_markdown)]
1696mod research_tests_ticket_023 {
1697    // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
1698    // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
1699    // produce ITERATION_LIMIT.
1700
1701    // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
1702    //
1703    // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
1704    // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
1705    //
1706    // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1707    unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
1708        use crate::ffi;
1709        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
1710        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
1711        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
1712        let row_lower: [f64; 2] = [6.0, 14.0];
1713        let row_upper: [f64; 2] = [6.0, 14.0];
1714        let a_start: [i32; 4] = [0, 2, 2, 3];
1715        let a_index: [i32; 3] = [0, 1, 1];
1716        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
1717        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1718        let status = unsafe {
1719            ffi::cobre_highs_pass_lp(
1720                highs,
1721                3,
1722                2,
1723                3,
1724                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1725                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1726                0.0,
1727                col_cost.as_ptr(),
1728                col_lower.as_ptr(),
1729                col_upper.as_ptr(),
1730                row_lower.as_ptr(),
1731                row_upper.as_ptr(),
1732                a_start.as_ptr(),
1733                a_index.as_ptr(),
1734                a_value.as_ptr(),
1735            )
1736        };
1737        assert_eq!(
1738            status,
1739            ffi::HIGHS_STATUS_OK,
1740            "research_load_ss11_lp pass_lp failed"
1741        );
1742    }
1743
1744    /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
1745    ///
1746    /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
1747    /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
1748    /// loop, making limits ineffective. This test documents that behavior.
1749    #[test]
1750    fn test_research_probe_limit_status_on_ss11_lp() {
1751        use crate::ffi;
1752
1753        // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
1754        let highs = unsafe { ffi::cobre_highs_create() };
1755        assert!(!highs.is_null());
1756        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1757        unsafe { research_load_ss11_lp(highs) };
1758        let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1759        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1760        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1761        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1762        eprintln!(
1763            "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1764        );
1765        unsafe { ffi::cobre_highs_destroy(highs) };
1766
1767        // SS1.1 with iteration_limit=0: same result, need a larger LP.
1768        let highs = unsafe { ffi::cobre_highs_create() };
1769        assert!(!highs.is_null());
1770        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1771        unsafe { research_load_ss11_lp(highs) };
1772        let _ = unsafe {
1773            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1774        };
1775        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1776        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1777        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1778        eprintln!(
1779            "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1780        );
1781        unsafe { ffi::cobre_highs_destroy(highs) };
1782    }
1783
1784    /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
1785    /// iterations and cannot be solved by crash alone.
1786    ///
1787    /// LP (larger_lp):
1788    ///   min  x0 + x1 + x2 + x3 + x4
1789    ///   s.t. x0 + x1              >= 10
1790    ///        x1 + x2              >= 8
1791    ///        x2 + x3              >= 6
1792    ///        x3 + x4              >= 4
1793    ///   x_i in [0, 100], i = 0..4
1794    ///
1795    /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
1796    ///   col 0: rows [0]       -> a_start[0]=0, a_start[1]=1
1797    ///   col 1: rows [0,1]     -> a_start[2]=3
1798    ///   col 2: rows [1,2]     -> a_start[3]=5
1799    ///   col 3: rows [2,3]     -> a_start[4]=7
1800    ///   col 4: rows [3]       -> a_start[5]=8
1801    ///
1802    /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1803    unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
1804        use crate::ffi;
1805        let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
1806        let col_lower: [f64; 5] = [0.0; 5];
1807        let col_upper: [f64; 5] = [100.0; 5];
1808        let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
1809        let row_upper: [f64; 4] = [f64::INFINITY; 4];
1810        // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
1811        let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
1812        let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
1813        let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1814        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1815        let status = unsafe {
1816            ffi::cobre_highs_pass_lp(
1817                highs,
1818                5,
1819                4,
1820                8,
1821                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1822                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1823                0.0,
1824                col_cost.as_ptr(),
1825                col_lower.as_ptr(),
1826                col_upper.as_ptr(),
1827                row_lower.as_ptr(),
1828                row_upper.as_ptr(),
1829                a_start.as_ptr(),
1830                a_index.as_ptr(),
1831                a_value.as_ptr(),
1832            )
1833        };
1834        assert_eq!(
1835            status,
1836            ffi::HIGHS_STATUS_OK,
1837            "research_load_larger_lp pass_lp failed"
1838        );
1839    }
1840
1841    /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
1842    ///
1843    /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
1844    /// crash. HiGHS checks the time limit at entry to the simplex loop.
1845    /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
1846    ///
1847    /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
1848    /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
1849    #[test]
1850    fn test_research_time_limit_zero_triggers_time_limit_status() {
1851        use crate::ffi;
1852
1853        let highs = unsafe { ffi::cobre_highs_create() };
1854        assert!(!highs.is_null());
1855        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1856        unsafe { research_load_larger_lp(highs) };
1857
1858        let opt_status =
1859            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1860        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1861
1862        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1863        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1864
1865        eprintln!(
1866            "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1867        );
1868
1869        assert_eq!(
1870            run_status,
1871            ffi::HIGHS_STATUS_WARNING,
1872            "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1873        );
1874        assert_eq!(
1875            model_status,
1876            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
1877            "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
1878        );
1879
1880        unsafe { ffi::cobre_highs_destroy(highs) };
1881    }
1882
1883    /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
1884    ///
1885    /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
1886    /// the crash phase does not solve it, and the iteration limit check fires.
1887    ///
1888    /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
1889    /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
1890    /// HighsModelStatus::kIterationLimit, iteration count = 0.
1891    #[test]
1892    fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
1893        use crate::ffi;
1894
1895        let highs = unsafe { ffi::cobre_highs_create() };
1896        assert!(!highs.is_null());
1897        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1898        // Disable presolve so crash cannot solve LP without simplex iterations.
1899        unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
1900        unsafe { research_load_larger_lp(highs) };
1901
1902        let opt_status = unsafe {
1903            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1904        };
1905        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1906
1907        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1908        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1909
1910        eprintln!(
1911            "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1912        );
1913
1914        assert_eq!(
1915            run_status,
1916            ffi::HIGHS_STATUS_WARNING,
1917            "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1918        );
1919        assert_eq!(
1920            model_status,
1921            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
1922            "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
1923        );
1924
1925        unsafe { ffi::cobre_highs_destroy(highs) };
1926    }
1927
1928    /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
1929    ///
1930    /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
1931    /// and presolve disabled, HiGHS halts at the crash-point solution.
1932    /// Both tests record objective availability for documentation.
1933    #[test]
1934    fn test_research_partial_solution_availability() {
1935        use crate::ffi;
1936
1937        // TIME_LIMIT: observe objective after halting at time check
1938        {
1939            let highs = unsafe { ffi::cobre_highs_create() };
1940            assert!(!highs.is_null());
1941            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1942            unsafe { research_load_larger_lp(highs) };
1943            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1944            unsafe { ffi::cobre_highs_run(highs) };
1945
1946            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1947            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1948            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
1949            eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
1950            unsafe { ffi::cobre_highs_destroy(highs) };
1951        }
1952
1953        // ITERATION_LIMIT: observe objective at crash point
1954        {
1955            let highs = unsafe { ffi::cobre_highs_create() };
1956            assert!(!highs.is_null());
1957            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1958            unsafe {
1959                ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
1960            };
1961            unsafe { research_load_larger_lp(highs) };
1962            unsafe {
1963                ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1964            };
1965            unsafe { ffi::cobre_highs_run(highs) };
1966
1967            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1968            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1969            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
1970            eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
1971            unsafe { ffi::cobre_highs_destroy(highs) };
1972        }
1973    }
1974
1975    /// Verify restore_default_settings: solve with iteration_limit=0, then solve
1976    /// without limit after restoring defaults. The second solve must succeed optimally.
1977    #[test]
1978    fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
1979        use crate::ffi;
1980
1981        let highs = unsafe { ffi::cobre_highs_create() };
1982        assert!(!highs.is_null());
1983
1984        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1985
1986        // Apply cobre defaults (mirror HighsSolver::new() configuration).
1987        unsafe {
1988            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
1989            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 4);
1990            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
1991            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
1992            ffi::cobre_highs_set_double_option(
1993                highs,
1994                c"primal_feasibility_tolerance".as_ptr(),
1995                1e-7,
1996            );
1997            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
1998        }
1999
2000        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2001        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2002        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2003        let row_lower: [f64; 2] = [6.0, 14.0];
2004        let row_upper: [f64; 2] = [6.0, 14.0];
2005        let a_start: [i32; 4] = [0, 2, 2, 3];
2006        let a_index: [i32; 3] = [0, 1, 1];
2007        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2008
2009        // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2010        unsafe {
2011            ffi::cobre_highs_pass_lp(
2012                highs,
2013                3,
2014                2,
2015                3,
2016                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2017                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2018                0.0,
2019                col_cost.as_ptr(),
2020                col_lower.as_ptr(),
2021                col_upper.as_ptr(),
2022                row_lower.as_ptr(),
2023                row_upper.as_ptr(),
2024                a_start.as_ptr(),
2025                a_index.as_ptr(),
2026                a_value.as_ptr(),
2027            );
2028            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2029            ffi::cobre_highs_run(highs);
2030        }
2031        let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2032        assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2033
2034        // Restore default settings (mirror restore_default_settings()).
2035        unsafe {
2036            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2037            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 4);
2038            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2039            ffi::cobre_highs_set_double_option(
2040                highs,
2041                c"primal_feasibility_tolerance".as_ptr(),
2042                1e-7,
2043            );
2044            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2045            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2046            ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2047            // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2048            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2049        }
2050
2051        // Second solve on the same model: must reach OPTIMAL.
2052        unsafe { ffi::cobre_highs_clear_solver(highs) };
2053        unsafe { ffi::cobre_highs_run(highs) };
2054        let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2055        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2056        assert_eq!(
2057            status2,
2058            ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2059            "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2060        );
2061        assert!(
2062            (obj - 100.0).abs() < 1e-8,
2063            "objective after restore must be 100.0, got {obj}"
2064        );
2065
2066        unsafe { ffi::cobre_highs_destroy(highs) };
2067    }
2068
2069    /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2070    ///
2071    /// This verifies that limiting to a small but non-zero number of iterations
2072    /// also works, providing an alternative formulation for triggering the same status.
2073    #[test]
2074    fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2075        use crate::ffi;
2076
2077        let highs = unsafe { ffi::cobre_highs_create() };
2078        assert!(!highs.is_null());
2079
2080        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2081
2082        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2083        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2084        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2085        let row_lower: [f64; 2] = [6.0, 14.0];
2086        let row_upper: [f64; 2] = [6.0, 14.0];
2087        let a_start: [i32; 4] = [0, 2, 2, 3];
2088        let a_index: [i32; 3] = [0, 1, 1];
2089        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2090
2091        unsafe {
2092            ffi::cobre_highs_pass_lp(
2093                highs,
2094                3,
2095                2,
2096                3,
2097                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2098                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2099                0.0,
2100                col_cost.as_ptr(),
2101                col_lower.as_ptr(),
2102                col_upper.as_ptr(),
2103                row_lower.as_ptr(),
2104                row_upper.as_ptr(),
2105                a_start.as_ptr(),
2106                a_index.as_ptr(),
2107                a_value.as_ptr(),
2108            );
2109            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2110            ffi::cobre_highs_run(highs);
2111        }
2112
2113        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2114        eprintln!("iteration_limit=1 model_status: {model_status}");
2115        // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2116        // We record both possibilities for the research document.
2117        assert!(
2118            model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2119                || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2120            "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2121        );
2122
2123        unsafe { ffi::cobre_highs_destroy(highs) };
2124    }
2125}