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