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