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