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(1), // Dual 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`             | `1`         | 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 basis_set_start = Instant::now();
1151        let set_status = unsafe {
1152            ffi::cobre_highs_set_basis(
1153                self.handle,
1154                self.basis_col_i32.as_ptr(),
1155                self.basis_row_i32.as_ptr(),
1156            )
1157        };
1158        self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1159
1160        // Basis rejection tracking: fall back to cold-start and track for diagnostics.
1161        if set_status == ffi::HIGHS_STATUS_ERROR {
1162            self.stats.basis_rejections += 1;
1163            debug_assert!(false, "raw basis rejected; falling back to cold-start");
1164        }
1165
1166        // Delegate to solve() which handles retry escalation and statistics updates.
1167        self.solve()
1168    }
1169
1170    fn statistics(&self) -> SolverStatistics {
1171        self.stats.clone()
1172    }
1173}
1174
1175/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1176///
1177/// Gated behind the `test-support` feature. The raw handle is intentionally not
1178/// part of the public API — callers use these methods to configure time/iteration
1179/// limits before a solve without going through the safe wrapper.
1180#[cfg(feature = "test-support")]
1181impl HighsSolver {
1182    /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1183    ///
1184    /// # Safety
1185    ///
1186    /// The returned pointer is valid for the lifetime of `self`. The caller must
1187    /// not store the pointer beyond that lifetime, must not call
1188    /// `cobre_highs_destroy` on it, and must not alias it across threads.
1189    #[must_use]
1190    pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1191        self.handle
1192    }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::HighsSolver;
1198    use crate::{
1199        SolverInterface,
1200        types::{Basis, RowBatch, StageTemplate},
1201    };
1202
1203    // Shared LP fixture from Solver Interface Testing SS1.1:
1204    // 3 variables, 2 structural constraints, 3 non-zeros.
1205    //
1206    //   min  0*x0 + 1*x1 + 50*x2
1207    //   s.t. x0            = 6   (state-fixing)
1208    //        2*x0 + x2     = 14  (power balance)
1209    //   x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1210    //
1211    // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1212    //   col_starts  = [0, 2, 2, 3]
1213    //   row_indices = [0, 1, 1]
1214    //   values      = [1.0, 2.0, 1.0]
1215    fn make_fixture_stage_template() -> StageTemplate {
1216        StageTemplate {
1217            num_cols: 3,
1218            num_rows: 2,
1219            num_nz: 3,
1220            col_starts: vec![0_i32, 2, 2, 3],
1221            row_indices: vec![0_i32, 1, 1],
1222            values: vec![1.0, 2.0, 1.0],
1223            col_lower: vec![0.0, 0.0, 0.0],
1224            col_upper: vec![10.0, f64::INFINITY, 8.0],
1225            objective: vec![0.0, 1.0, 50.0],
1226            row_lower: vec![6.0, 14.0],
1227            row_upper: vec![6.0, 14.0],
1228            n_state: 1,
1229            n_transfer: 0,
1230            n_dual_relevant: 1,
1231            n_hydro: 1,
1232            max_par_order: 0,
1233            col_scale: Vec::new(),
1234            row_scale: Vec::new(),
1235        }
1236    }
1237
1238    // Benders cut fixture from Solver Interface Testing SS1.2:
1239    // Cut 1: -5*x0 + x1 >= 20  (col_indices [0,1], values [-5, 1])
1240    // Cut 2:  3*x0 + x1 >= 80  (col_indices [0,1], values [ 3, 1])
1241    fn make_fixture_row_batch() -> RowBatch {
1242        RowBatch {
1243            num_rows: 2,
1244            row_starts: vec![0_i32, 2, 4],
1245            col_indices: vec![0_i32, 1, 0, 1],
1246            values: vec![-5.0, 1.0, 3.0, 1.0],
1247            row_lower: vec![20.0, 80.0],
1248            row_upper: vec![f64::INFINITY, f64::INFINITY],
1249        }
1250    }
1251
1252    #[test]
1253    fn test_highs_solver_create_and_name() {
1254        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1255        assert_eq!(solver.name(), "HiGHS");
1256        // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1257    }
1258
1259    #[test]
1260    fn test_highs_solver_send_bound() {
1261        fn assert_send<T: Send>() {}
1262        assert_send::<HighsSolver>();
1263    }
1264
1265    #[test]
1266    fn test_highs_solver_statistics_initial() {
1267        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1268        let stats = solver.statistics();
1269        assert_eq!(stats.solve_count, 0);
1270        assert_eq!(stats.success_count, 0);
1271        assert_eq!(stats.failure_count, 0);
1272        assert_eq!(stats.total_iterations, 0);
1273        assert_eq!(stats.retry_count, 0);
1274        assert_eq!(stats.total_solve_time_seconds, 0.0);
1275    }
1276
1277    #[test]
1278    fn test_highs_load_model_updates_dimensions() {
1279        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1280        let template = make_fixture_stage_template();
1281
1282        solver.load_model(&template);
1283
1284        assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1285        assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1286        assert_eq!(
1287            solver.col_value.len(),
1288            3,
1289            "col_value buffer must be resized to num_cols"
1290        );
1291        assert_eq!(
1292            solver.col_dual.len(),
1293            3,
1294            "col_dual buffer must be resized to num_cols"
1295        );
1296        assert_eq!(
1297            solver.row_value.len(),
1298            2,
1299            "row_value buffer must be resized to num_rows"
1300        );
1301        assert_eq!(
1302            solver.row_dual.len(),
1303            2,
1304            "row_dual buffer must be resized to num_rows"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_highs_add_rows_updates_dimensions() {
1310        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1311        let template = make_fixture_stage_template();
1312        let cuts = make_fixture_row_batch();
1313
1314        solver.load_model(&template);
1315        solver.add_rows(&cuts);
1316
1317        // 2 structural rows + 2 cut rows = 4
1318        assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1319        assert_eq!(
1320            solver.row_dual.len(),
1321            4,
1322            "row_dual buffer must be resized to 4 after add_rows"
1323        );
1324        assert_eq!(
1325            solver.row_value.len(),
1326            4,
1327            "row_value buffer must be resized to 4 after add_rows"
1328        );
1329        // Columns unchanged
1330        assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1331    }
1332
1333    #[test]
1334    fn test_highs_set_row_bounds_no_panic() {
1335        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1336        let template = make_fixture_stage_template();
1337        solver.load_model(&template);
1338
1339        // Patch row 0 to equality at 4.0. Must complete without panic.
1340        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1341    }
1342
1343    #[test]
1344    fn test_highs_set_col_bounds_no_panic() {
1345        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1346        let template = make_fixture_stage_template();
1347        solver.load_model(&template);
1348
1349        // Patch column 1 lower bound to 10.0. Must complete without panic.
1350        solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1351    }
1352
1353    #[test]
1354    fn test_highs_set_bounds_empty_no_panic() {
1355        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1356        let template = make_fixture_stage_template();
1357        solver.load_model(&template);
1358
1359        // Empty patch slices should be short-circuited without any FFI call.
1360        solver.set_row_bounds(&[], &[], &[]);
1361        solver.set_col_bounds(&[], &[], &[]);
1362    }
1363
1364    /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1365    /// Optimal: x0=6, x1=0, x2=2, objective=100.
1366    #[test]
1367    fn test_highs_solve_basic_lp() {
1368        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1369        let template = make_fixture_stage_template();
1370        solver.load_model(&template);
1371
1372        let solution = solver
1373            .solve()
1374            .expect("solve() must succeed on a feasible LP");
1375
1376        assert!(
1377            (solution.objective - 100.0).abs() < 1e-8,
1378            "objective must be 100.0, got {}",
1379            solution.objective
1380        );
1381        assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1382        assert!(
1383            (solution.primal[0] - 6.0).abs() < 1e-8,
1384            "primal[0] (x0) must be 6.0, got {}",
1385            solution.primal[0]
1386        );
1387        assert!(
1388            (solution.primal[1] - 0.0).abs() < 1e-8,
1389            "primal[1] (x1) must be 0.0, got {}",
1390            solution.primal[1]
1391        );
1392        assert!(
1393            (solution.primal[2] - 2.0).abs() < 1e-8,
1394            "primal[2] (x2) must be 2.0, got {}",
1395            solution.primal[2]
1396        );
1397    }
1398
1399    /// SS1.2: after adding two Benders cuts to SS1.1, optimal objective = 162.
1400    /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1401    /// Obj = 0*6 + 1*62 + 50*2 = 162.
1402    #[test]
1403    fn test_highs_solve_with_cuts() {
1404        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1405        let template = make_fixture_stage_template();
1406        let cuts = make_fixture_row_batch();
1407        solver.load_model(&template);
1408        solver.add_rows(&cuts);
1409
1410        let solution = solver
1411            .solve()
1412            .expect("solve() must succeed on a feasible LP with cuts");
1413
1414        assert!(
1415            (solution.objective - 162.0).abs() < 1e-8,
1416            "objective must be 162.0, got {}",
1417            solution.objective
1418        );
1419        assert!(
1420            (solution.primal[0] - 6.0).abs() < 1e-8,
1421            "primal[0] must be 6.0, got {}",
1422            solution.primal[0]
1423        );
1424        assert!(
1425            (solution.primal[1] - 62.0).abs() < 1e-8,
1426            "primal[1] must be 62.0, got {}",
1427            solution.primal[1]
1428        );
1429        assert!(
1430            (solution.primal[2] - 2.0).abs() < 1e-8,
1431            "primal[2] must be 2.0, got {}",
1432            solution.primal[2]
1433        );
1434    }
1435
1436    /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1437    /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1438    #[test]
1439    fn test_highs_solve_after_rhs_patch() {
1440        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1441        let template = make_fixture_stage_template();
1442        let cuts = make_fixture_row_batch();
1443        solver.load_model(&template);
1444        solver.add_rows(&cuts);
1445
1446        // Patch row 0 (x0=6 equality) to x0=4.
1447        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1448
1449        let solution = solver
1450            .solve()
1451            .expect("solve() must succeed after RHS patch");
1452
1453        assert!(
1454            (solution.objective - 368.0).abs() < 1e-8,
1455            "objective must be 368.0, got {}",
1456            solution.objective
1457        );
1458    }
1459
1460    /// After two successful solves, statistics must reflect both.
1461    #[test]
1462    fn test_highs_solve_statistics_increment() {
1463        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1464        let template = make_fixture_stage_template();
1465        solver.load_model(&template);
1466
1467        solver.solve().expect("first solve must succeed");
1468        solver.solve().expect("second solve must succeed");
1469
1470        let stats = solver.statistics();
1471        assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1472        assert_eq!(stats.success_count, 2, "success_count must be 2");
1473        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1474        assert!(
1475            stats.total_iterations > 0,
1476            "total_iterations must be positive"
1477        );
1478    }
1479
1480    /// After `reset()`, statistics counters must be unchanged.
1481    #[test]
1482    fn test_highs_reset_preserves_stats() {
1483        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1484        let template = make_fixture_stage_template();
1485        solver.load_model(&template);
1486        solver.solve().expect("solve must succeed");
1487
1488        let stats_before = solver.statistics();
1489        assert_eq!(
1490            stats_before.solve_count, 1,
1491            "solve_count must be 1 before reset"
1492        );
1493
1494        solver.reset();
1495
1496        let stats_after = solver.statistics();
1497        assert_eq!(
1498            stats_after.solve_count, stats_before.solve_count,
1499            "solve_count must be unchanged after reset"
1500        );
1501        assert_eq!(
1502            stats_after.success_count, stats_before.success_count,
1503            "success_count must be unchanged after reset"
1504        );
1505        assert_eq!(
1506            stats_after.total_iterations, stats_before.total_iterations,
1507            "total_iterations must be unchanged after reset"
1508        );
1509    }
1510
1511    /// The first solve must report a positive iteration count.
1512    #[test]
1513    fn test_highs_solve_iterations_positive() {
1514        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1515        let template = make_fixture_stage_template();
1516        solver.load_model(&template);
1517
1518        let solution = solver.solve().expect("solve must succeed");
1519        assert!(
1520            solution.iterations > 0,
1521            "iterations must be positive, got {}",
1522            solution.iterations
1523        );
1524    }
1525
1526    /// The first solve must report a positive wall-clock time.
1527    #[test]
1528    fn test_highs_solve_time_positive() {
1529        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1530        let template = make_fixture_stage_template();
1531        solver.load_model(&template);
1532
1533        let solution = solver.solve().expect("solve must succeed");
1534        assert!(
1535            solution.solve_time_seconds > 0.0,
1536            "solve_time_seconds must be positive, got {}",
1537            solution.solve_time_seconds
1538        );
1539    }
1540
1541    /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1542    /// `failure_count==0`, and `total_iterations` > 0.
1543    #[test]
1544    fn test_highs_solve_statistics_single() {
1545        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1546        let template = make_fixture_stage_template();
1547        solver.load_model(&template);
1548
1549        solver.solve().expect("solve must succeed");
1550
1551        let stats = solver.statistics();
1552        assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1553        assert_eq!(stats.success_count, 1, "success_count must be 1");
1554        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1555        assert!(
1556            stats.total_iterations > 0,
1557            "total_iterations must be positive after a successful solve"
1558        );
1559    }
1560
1561    /// After `load_model` + `solve()`, `get_basis` must return i32 codes
1562    /// that are all valid `HiGHS` basis status values (0..=4).
1563    #[test]
1564    fn test_get_basis_valid_status_codes() {
1565        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1566        let template = make_fixture_stage_template();
1567        solver.load_model(&template);
1568        solver.solve().expect("solve must succeed before get_basis");
1569
1570        let mut basis = Basis::new(0, 0);
1571        solver.get_basis(&mut basis);
1572
1573        for &code in &basis.col_status {
1574            assert!(
1575                (0..=4).contains(&code),
1576                "col_status code {code} is outside valid HiGHS range 0..=4"
1577            );
1578        }
1579        for &code in &basis.row_status {
1580            assert!(
1581                (0..=4).contains(&code),
1582                "row_status code {code} is outside valid HiGHS range 0..=4"
1583            );
1584        }
1585    }
1586
1587    /// Starting from an empty `Basis`, `get_basis` must resize the output
1588    /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
1589    #[test]
1590    fn test_get_basis_resizes_output() {
1591        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1592        let template = make_fixture_stage_template();
1593        solver.load_model(&template);
1594        solver.solve().expect("solve must succeed before get_basis");
1595
1596        let mut basis = Basis::new(0, 0);
1597        assert_eq!(
1598            basis.col_status.len(),
1599            0,
1600            "initial col_status must be empty"
1601        );
1602        assert_eq!(
1603            basis.row_status.len(),
1604            0,
1605            "initial row_status must be empty"
1606        );
1607
1608        solver.get_basis(&mut basis);
1609
1610        assert_eq!(
1611            basis.col_status.len(),
1612            3,
1613            "col_status must be resized to 3 (num_cols of SS1.1)"
1614        );
1615        assert_eq!(
1616            basis.row_status.len(),
1617            2,
1618            "row_status must be resized to 2 (num_rows of SS1.1)"
1619        );
1620    }
1621
1622    /// Warm-start via `solve_with_basis` on the same LP must reproduce
1623    /// the optimal objective and complete in at most 1 simplex iteration.
1624    #[test]
1625    fn test_solve_with_basis_warm_start() {
1626        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1627        let template = make_fixture_stage_template();
1628        solver.load_model(&template);
1629        solver.solve().expect("cold-start solve must succeed");
1630
1631        let mut basis = Basis::new(0, 0);
1632        solver.get_basis(&mut basis);
1633
1634        // Reload the same model to reset HiGHS internal state.
1635        solver.load_model(&template);
1636        let result = solver
1637            .solve_with_basis(&basis)
1638            .expect("warm-start solve must succeed");
1639
1640        assert!(
1641            (result.objective - 100.0).abs() < 1e-8,
1642            "warm-start objective must be 100.0, got {}",
1643            result.objective
1644        );
1645        assert!(
1646            result.iterations <= 1,
1647            "warm-start from exact basis must use at most 1 iteration, got {}",
1648            result.iterations
1649        );
1650
1651        let stats = solver.statistics();
1652        assert_eq!(
1653            stats.basis_rejections, 0,
1654            "basis_rejections must be 0 when raw basis is accepted, got {}",
1655            stats.basis_rejections
1656        );
1657    }
1658
1659    /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
1660    /// `solve_with_basis` must extend missing rows as Basic and solve correctly.
1661    /// SS1.2 objective with both cuts active is 162.0.
1662    #[test]
1663    fn test_solve_with_basis_dimension_mismatch() {
1664        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1665        let template = make_fixture_stage_template();
1666        let cuts = make_fixture_row_batch();
1667
1668        // First solve on 2-row LP to capture a 2-row basis.
1669        solver.load_model(&template);
1670        solver.solve().expect("SS1.1 solve must succeed");
1671        let mut basis = Basis::new(0, 0);
1672        solver.get_basis(&mut basis);
1673        assert_eq!(
1674            basis.row_status.len(),
1675            2,
1676            "captured basis must have 2 row statuses"
1677        );
1678
1679        // Reload model and add 2 cuts to get a 4-row LP.
1680        solver.load_model(&template);
1681        solver.add_rows(&cuts);
1682        assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
1683
1684        // Warm-start with the 2-row basis; extra rows are extended as Basic.
1685        let result = solver
1686            .solve_with_basis(&basis)
1687            .expect("solve with dimension-mismatched basis must succeed");
1688
1689        assert!(
1690            (result.objective - 162.0).abs() < 1e-8,
1691            "objective with both cuts active must be 162.0, got {}",
1692            result.objective
1693        );
1694    }
1695}
1696
1697// ─── Research verification tests for non-optimal HiGHS model statuses ────
1698//
1699// These tests verify LP formulations that reliably trigger non-optimal
1700// HiGHS model statuses. They use the raw FFI layer to set options not
1701// exposed through SolverInterface and confirm the expected model status.
1702// Findings are documented in:
1703//   plans/phase-3-solver/epic-08-coverage/research-edge-case-lps.md
1704//
1705// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
1706// heuristic solves it without entering the simplex loop, so time/iteration
1707// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
1708#[cfg(test)]
1709#[allow(clippy::doc_markdown)]
1710mod research_tests_ticket_023 {
1711    // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
1712    // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
1713    // produce ITERATION_LIMIT.
1714
1715    // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
1716    //
1717    // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
1718    // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
1719    //
1720    // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1721    unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
1722        use crate::ffi;
1723        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
1724        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
1725        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
1726        let row_lower: [f64; 2] = [6.0, 14.0];
1727        let row_upper: [f64; 2] = [6.0, 14.0];
1728        let a_start: [i32; 4] = [0, 2, 2, 3];
1729        let a_index: [i32; 3] = [0, 1, 1];
1730        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
1731        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1732        let status = unsafe {
1733            ffi::cobre_highs_pass_lp(
1734                highs,
1735                3,
1736                2,
1737                3,
1738                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1739                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1740                0.0,
1741                col_cost.as_ptr(),
1742                col_lower.as_ptr(),
1743                col_upper.as_ptr(),
1744                row_lower.as_ptr(),
1745                row_upper.as_ptr(),
1746                a_start.as_ptr(),
1747                a_index.as_ptr(),
1748                a_value.as_ptr(),
1749            )
1750        };
1751        assert_eq!(
1752            status,
1753            ffi::HIGHS_STATUS_OK,
1754            "research_load_ss11_lp pass_lp failed"
1755        );
1756    }
1757
1758    /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
1759    ///
1760    /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
1761    /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
1762    /// loop, making limits ineffective. This test documents that behavior.
1763    #[test]
1764    fn test_research_probe_limit_status_on_ss11_lp() {
1765        use crate::ffi;
1766
1767        // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
1768        let highs = unsafe { ffi::cobre_highs_create() };
1769        assert!(!highs.is_null());
1770        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1771        unsafe { research_load_ss11_lp(highs) };
1772        let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1773        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1774        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1775        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1776        eprintln!(
1777            "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1778        );
1779        unsafe { ffi::cobre_highs_destroy(highs) };
1780
1781        // SS1.1 with iteration_limit=0: same result, need a larger LP.
1782        let highs = unsafe { ffi::cobre_highs_create() };
1783        assert!(!highs.is_null());
1784        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1785        unsafe { research_load_ss11_lp(highs) };
1786        let _ = unsafe {
1787            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1788        };
1789        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1790        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1791        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1792        eprintln!(
1793            "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1794        );
1795        unsafe { ffi::cobre_highs_destroy(highs) };
1796    }
1797
1798    /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
1799    /// iterations and cannot be solved by crash alone.
1800    ///
1801    /// LP (larger_lp):
1802    ///   min  x0 + x1 + x2 + x3 + x4
1803    ///   s.t. x0 + x1              >= 10
1804    ///        x1 + x2              >= 8
1805    ///        x2 + x3              >= 6
1806    ///        x3 + x4              >= 4
1807    ///   x_i in [0, 100], i = 0..4
1808    ///
1809    /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
1810    ///   col 0: rows [0]       -> a_start[0]=0, a_start[1]=1
1811    ///   col 1: rows [0,1]     -> a_start[2]=3
1812    ///   col 2: rows [1,2]     -> a_start[3]=5
1813    ///   col 3: rows [2,3]     -> a_start[4]=7
1814    ///   col 4: rows [3]       -> a_start[5]=8
1815    ///
1816    /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1817    unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
1818        use crate::ffi;
1819        let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
1820        let col_lower: [f64; 5] = [0.0; 5];
1821        let col_upper: [f64; 5] = [100.0; 5];
1822        let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
1823        let row_upper: [f64; 4] = [f64::INFINITY; 4];
1824        // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
1825        let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
1826        let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
1827        let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1828        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1829        let status = unsafe {
1830            ffi::cobre_highs_pass_lp(
1831                highs,
1832                5,
1833                4,
1834                8,
1835                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1836                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1837                0.0,
1838                col_cost.as_ptr(),
1839                col_lower.as_ptr(),
1840                col_upper.as_ptr(),
1841                row_lower.as_ptr(),
1842                row_upper.as_ptr(),
1843                a_start.as_ptr(),
1844                a_index.as_ptr(),
1845                a_value.as_ptr(),
1846            )
1847        };
1848        assert_eq!(
1849            status,
1850            ffi::HIGHS_STATUS_OK,
1851            "research_load_larger_lp pass_lp failed"
1852        );
1853    }
1854
1855    /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
1856    ///
1857    /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
1858    /// crash. HiGHS checks the time limit at entry to the simplex loop.
1859    /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
1860    ///
1861    /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
1862    /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
1863    #[test]
1864    fn test_research_time_limit_zero_triggers_time_limit_status() {
1865        use crate::ffi;
1866
1867        let highs = unsafe { ffi::cobre_highs_create() };
1868        assert!(!highs.is_null());
1869        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1870        unsafe { research_load_larger_lp(highs) };
1871
1872        let opt_status =
1873            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1874        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1875
1876        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1877        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1878
1879        eprintln!(
1880            "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1881        );
1882
1883        assert_eq!(
1884            run_status,
1885            ffi::HIGHS_STATUS_WARNING,
1886            "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1887        );
1888        assert_eq!(
1889            model_status,
1890            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
1891            "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
1892        );
1893
1894        unsafe { ffi::cobre_highs_destroy(highs) };
1895    }
1896
1897    /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
1898    ///
1899    /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
1900    /// the crash phase does not solve it, and the iteration limit check fires.
1901    ///
1902    /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
1903    /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
1904    /// HighsModelStatus::kIterationLimit, iteration count = 0.
1905    #[test]
1906    fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
1907        use crate::ffi;
1908
1909        let highs = unsafe { ffi::cobre_highs_create() };
1910        assert!(!highs.is_null());
1911        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1912        // Disable presolve so crash cannot solve LP without simplex iterations.
1913        unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
1914        unsafe { research_load_larger_lp(highs) };
1915
1916        let opt_status = unsafe {
1917            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1918        };
1919        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1920
1921        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1922        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1923
1924        eprintln!(
1925            "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1926        );
1927
1928        assert_eq!(
1929            run_status,
1930            ffi::HIGHS_STATUS_WARNING,
1931            "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1932        );
1933        assert_eq!(
1934            model_status,
1935            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
1936            "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
1937        );
1938
1939        unsafe { ffi::cobre_highs_destroy(highs) };
1940    }
1941
1942    /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
1943    ///
1944    /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
1945    /// and presolve disabled, HiGHS halts at the crash-point solution.
1946    /// Both tests record objective availability for documentation.
1947    #[test]
1948    fn test_research_partial_solution_availability() {
1949        use crate::ffi;
1950
1951        // TIME_LIMIT: observe objective after halting at time check
1952        {
1953            let highs = unsafe { ffi::cobre_highs_create() };
1954            assert!(!highs.is_null());
1955            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1956            unsafe { research_load_larger_lp(highs) };
1957            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1958            unsafe { ffi::cobre_highs_run(highs) };
1959
1960            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1961            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1962            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
1963            eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
1964            unsafe { ffi::cobre_highs_destroy(highs) };
1965        }
1966
1967        // ITERATION_LIMIT: observe objective at crash point
1968        {
1969            let highs = unsafe { ffi::cobre_highs_create() };
1970            assert!(!highs.is_null());
1971            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1972            unsafe {
1973                ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
1974            };
1975            unsafe { research_load_larger_lp(highs) };
1976            unsafe {
1977                ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1978            };
1979            unsafe { ffi::cobre_highs_run(highs) };
1980
1981            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1982            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1983            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
1984            eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
1985            unsafe { ffi::cobre_highs_destroy(highs) };
1986        }
1987    }
1988
1989    /// Verify restore_default_settings: solve with iteration_limit=0, then solve
1990    /// without limit after restoring defaults. The second solve must succeed optimally.
1991    #[test]
1992    fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
1993        use crate::ffi;
1994
1995        let highs = unsafe { ffi::cobre_highs_create() };
1996        assert!(!highs.is_null());
1997
1998        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1999
2000        // Apply cobre defaults (mirror HighsSolver::new() configuration).
2001        unsafe {
2002            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2003            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2004            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2005            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2006            ffi::cobre_highs_set_double_option(
2007                highs,
2008                c"primal_feasibility_tolerance".as_ptr(),
2009                1e-7,
2010            );
2011            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2012        }
2013
2014        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2015        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2016        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2017        let row_lower: [f64; 2] = [6.0, 14.0];
2018        let row_upper: [f64; 2] = [6.0, 14.0];
2019        let a_start: [i32; 4] = [0, 2, 2, 3];
2020        let a_index: [i32; 3] = [0, 1, 1];
2021        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2022
2023        // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2024        unsafe {
2025            ffi::cobre_highs_pass_lp(
2026                highs,
2027                3,
2028                2,
2029                3,
2030                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2031                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2032                0.0,
2033                col_cost.as_ptr(),
2034                col_lower.as_ptr(),
2035                col_upper.as_ptr(),
2036                row_lower.as_ptr(),
2037                row_upper.as_ptr(),
2038                a_start.as_ptr(),
2039                a_index.as_ptr(),
2040                a_value.as_ptr(),
2041            );
2042            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2043            ffi::cobre_highs_run(highs);
2044        }
2045        let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2046        assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2047
2048        // Restore default settings (mirror restore_default_settings()).
2049        unsafe {
2050            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2051            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2052            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2053            ffi::cobre_highs_set_double_option(
2054                highs,
2055                c"primal_feasibility_tolerance".as_ptr(),
2056                1e-7,
2057            );
2058            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2059            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2060            ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2061            // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2062            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2063        }
2064
2065        // Second solve on the same model: must reach OPTIMAL.
2066        unsafe { ffi::cobre_highs_clear_solver(highs) };
2067        unsafe { ffi::cobre_highs_run(highs) };
2068        let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2069        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2070        assert_eq!(
2071            status2,
2072            ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2073            "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2074        );
2075        assert!(
2076            (obj - 100.0).abs() < 1e-8,
2077            "objective after restore must be 100.0, got {obj}"
2078        );
2079
2080        unsafe { ffi::cobre_highs_destroy(highs) };
2081    }
2082
2083    /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2084    ///
2085    /// This verifies that limiting to a small but non-zero number of iterations
2086    /// also works, providing an alternative formulation for triggering the same status.
2087    #[test]
2088    fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2089        use crate::ffi;
2090
2091        let highs = unsafe { ffi::cobre_highs_create() };
2092        assert!(!highs.is_null());
2093
2094        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2095
2096        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2097        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2098        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2099        let row_lower: [f64; 2] = [6.0, 14.0];
2100        let row_upper: [f64; 2] = [6.0, 14.0];
2101        let a_start: [i32; 4] = [0, 2, 2, 3];
2102        let a_index: [i32; 3] = [0, 1, 1];
2103        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2104
2105        unsafe {
2106            ffi::cobre_highs_pass_lp(
2107                highs,
2108                3,
2109                2,
2110                3,
2111                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2112                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2113                0.0,
2114                col_cost.as_ptr(),
2115                col_lower.as_ptr(),
2116                col_upper.as_ptr(),
2117                row_lower.as_ptr(),
2118                row_upper.as_ptr(),
2119                a_start.as_ptr(),
2120                a_index.as_ptr(),
2121                a_value.as_ptr(),
2122            );
2123            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2124            ffi::cobre_highs_run(highs);
2125        }
2126
2127        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2128        eprintln!("iteration_limit=1 model_status: {model_status}");
2129        // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2130        // We record both possibilities for the research document.
2131        assert!(
2132            model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2133                || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2134            "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2135        );
2136
2137        unsafe { ffi::cobre_highs_destroy(highs) };
2138    }
2139}