Skip to main content

cobre_solver/
highs.rs

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