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
786/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
787///
788/// This is a free function — no solver instance is required.
789///
790/// # Example
791///
792/// ```rust
793/// # #[cfg(feature = "highs")]
794/// # {
795/// let v = cobre_solver::highs_version();
796/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
797/// # }
798/// ```
799#[must_use]
800pub fn highs_version() -> String {
801    // SAFETY: These are pure query functions with no arguments. The HiGHS C API
802    // documents them as safe to call without any prior initialisation; they read
803    // only compile-time constants embedded in the library.
804    let major = unsafe { crate::ffi::cobre_highs_version_major() };
805    let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
806    let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
807    format!("{major}.{minor}.{patch}")
808}
809
810impl SolverInterface for HighsSolver {
811    fn name(&self) -> &'static str {
812        "HiGHS"
813    }
814
815    fn solver_name_version(&self) -> String {
816        format!("HiGHS {}", highs_version())
817    }
818
819    fn load_model(&mut self, template: &StageTemplate) {
820        let t0 = Instant::now();
821        // SAFETY:
822        // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
823        // - All pointer arguments point into owned `Vec` data that remains alive for the
824        //   duration of this call.
825        // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
826        //   template, alive for the duration of this borrow.
827        // - All slice lengths match the HiGHS API contract:
828        //   `num_col + 1` for a_start, `num_nz` for a_index and a_value,
829        //   `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
830        assert!(
831            i32::try_from(template.num_cols).is_ok(),
832            "num_cols {} overflows i32: LP exceeds HiGHS API limit",
833            template.num_cols
834        );
835        assert!(
836            i32::try_from(template.num_rows).is_ok(),
837            "num_rows {} overflows i32: LP exceeds HiGHS API limit",
838            template.num_rows
839        );
840        assert!(
841            i32::try_from(template.num_nz).is_ok(),
842            "num_nz {} overflows i32: LP exceeds HiGHS API limit",
843            template.num_nz
844        );
845        // SAFETY: All three values have been asserted to fit in i32 above.
846        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
847        let num_col = template.num_cols as i32;
848        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
849        let num_row = template.num_rows as i32;
850        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
851        let num_nz = template.num_nz as i32;
852        let status = unsafe {
853            ffi::cobre_highs_pass_lp(
854                self.handle,
855                num_col,
856                num_row,
857                num_nz,
858                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
859                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
860                0.0, // objective offset
861                template.objective.as_ptr(),
862                template.col_lower.as_ptr(),
863                template.col_upper.as_ptr(),
864                template.row_lower.as_ptr(),
865                template.row_upper.as_ptr(),
866                template.col_starts.as_ptr(),
867                template.row_indices.as_ptr(),
868                template.values.as_ptr(),
869            )
870        };
871
872        assert_ne!(
873            status,
874            ffi::HIGHS_STATUS_ERROR,
875            "cobre_highs_pass_lp failed with status {status}"
876        );
877
878        self.num_cols = template.num_cols;
879        self.num_rows = template.num_rows;
880        self.has_model = true;
881
882        // Resize solution extraction buffers to match the new LP dimensions.
883        // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
884        self.col_value.resize(self.num_cols, 0.0);
885        self.col_dual.resize(self.num_cols, 0.0);
886        self.row_value.resize(self.num_rows, 0.0);
887        self.row_dual.resize(self.num_rows, 0.0);
888
889        // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
890        // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
891        self.basis_col_i32.resize(self.num_cols, 0);
892        self.basis_row_i32.resize(self.num_rows, 0);
893        self.stats.total_load_model_time_seconds += t0.elapsed().as_secs_f64();
894        self.stats.load_model_count += 1;
895    }
896
897    fn add_rows(&mut self, cuts: &RowBatch) {
898        let t0 = Instant::now();
899        assert!(
900            i32::try_from(cuts.num_rows).is_ok(),
901            "cuts.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
902            cuts.num_rows
903        );
904        assert!(
905            i32::try_from(cuts.col_indices.len()).is_ok(),
906            "cuts nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
907            cuts.col_indices.len()
908        );
909        // SAFETY: Both values have been asserted to fit in i32 above.
910        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
911        let num_new_row = cuts.num_rows as i32;
912        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
913        let num_new_nz = cuts.col_indices.len() as i32;
914
915        // SAFETY:
916        // - `self.handle` is a valid, non-null HiGHS pointer.
917        // - All pointer arguments point into owned data alive for the duration of this call.
918        // - `cuts.row_starts` and `cuts.col_indices` are `Vec<i32>` owned by the RowBatch,
919        //   alive for the duration of this borrow.
920        // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
921        //   `num_rows` for lower/upper bounds.
922        let status = unsafe {
923            ffi::cobre_highs_add_rows(
924                self.handle,
925                num_new_row,
926                cuts.row_lower.as_ptr(),
927                cuts.row_upper.as_ptr(),
928                num_new_nz,
929                cuts.row_starts.as_ptr(),
930                cuts.col_indices.as_ptr(),
931                cuts.values.as_ptr(),
932            )
933        };
934
935        assert_ne!(
936            status,
937            ffi::HIGHS_STATUS_ERROR,
938            "cobre_highs_add_rows failed with status {status}"
939        );
940
941        self.num_rows += cuts.num_rows;
942
943        // Grow row-indexed solution extraction buffers to cover the new rows.
944        self.row_value.resize(self.num_rows, 0.0);
945        self.row_dual.resize(self.num_rows, 0.0);
946
947        // Grow basis row i32 buffer to cover the new rows.
948        self.basis_row_i32.resize(self.num_rows, 0);
949        self.stats.total_add_rows_time_seconds += t0.elapsed().as_secs_f64();
950        self.stats.add_rows_count += 1;
951    }
952
953    fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
954        assert!(
955            indices.len() == lower.len() && indices.len() == upper.len(),
956            "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
957            indices.len(),
958            lower.len(),
959            upper.len()
960        );
961        if indices.is_empty() {
962            return;
963        }
964
965        assert!(
966            i32::try_from(indices.len()).is_ok(),
967            "set_row_bounds: indices.len() {} overflows i32",
968            indices.len()
969        );
970        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
971        let num_entries = indices.len() as i32;
972
973        let t0 = Instant::now();
974        // SAFETY:
975        // - `self.handle` is a valid, non-null HiGHS pointer.
976        // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
977        //   alive for `'self`. Pointer is used immediately in the FFI call.
978        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
979        // - `num_entries` equals the lengths of all three arrays.
980        let status = unsafe {
981            ffi::cobre_highs_change_rows_bounds_by_set(
982                self.handle,
983                num_entries,
984                self.convert_to_i32_scratch(indices).as_ptr(),
985                lower.as_ptr(),
986                upper.as_ptr(),
987            )
988        };
989
990        assert_ne!(
991            status,
992            ffi::HIGHS_STATUS_ERROR,
993            "cobre_highs_change_rows_bounds_by_set failed with status {status}"
994        );
995        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
996    }
997
998    fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
999        assert!(
1000            indices.len() == lower.len() && indices.len() == upper.len(),
1001            "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1002            indices.len(),
1003            lower.len(),
1004            upper.len()
1005        );
1006        if indices.is_empty() {
1007            return;
1008        }
1009
1010        assert!(
1011            i32::try_from(indices.len()).is_ok(),
1012            "set_col_bounds: indices.len() {} overflows i32",
1013            indices.len()
1014        );
1015        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1016        let num_entries = indices.len() as i32;
1017
1018        let t0 = Instant::now();
1019        // SAFETY:
1020        // - `self.handle` is a valid, non-null HiGHS pointer.
1021        // - Converted indices point into `self.scratch_i32`, alive for `'self`.
1022        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1023        // - `num_entries` equals the lengths of all three arrays.
1024        let status = unsafe {
1025            ffi::cobre_highs_change_cols_bounds_by_set(
1026                self.handle,
1027                num_entries,
1028                self.convert_to_i32_scratch(indices).as_ptr(),
1029                lower.as_ptr(),
1030                upper.as_ptr(),
1031            )
1032        };
1033
1034        assert_ne!(
1035            status,
1036            ffi::HIGHS_STATUS_ERROR,
1037            "cobre_highs_change_cols_bounds_by_set failed with status {status}"
1038        );
1039        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1040    }
1041
1042    fn solve(&mut self) -> Result<SolutionView<'_>, SolverError> {
1043        assert!(
1044            self.has_model,
1045            "solve called without a loaded model — call load_model first"
1046        );
1047
1048        // Safeguard: apply iteration limits before the initial attempt.
1049        // Time limits are NOT set here — HiGHS tracks time cumulatively from
1050        // instance creation, so a per-solve time_limit would fire spuriously
1051        // on long-running solver instances. Instead, wall-clock time is checked
1052        // after run_once() to detect stuck solves.
1053        self.set_iteration_limits();
1054
1055        let t0 = Instant::now();
1056        let model_status = self.run_once();
1057        let solve_time = t0.elapsed().as_secs_f64();
1058
1059        self.stats.solve_count += 1;
1060
1061        if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
1062            // Read iteration count from FFI BEFORE establishing the shared borrow
1063            // via extract_solution_view, so stats can be updated without violating
1064            // the aliasing rules.
1065            // SAFETY: handle is valid non-null HiGHS pointer.
1066            #[allow(clippy::cast_sign_loss)]
1067            let iterations =
1068                unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
1069            self.stats.success_count += 1;
1070            self.stats.first_try_successes += 1;
1071            self.stats.total_iterations += iterations;
1072            self.stats.total_solve_time_seconds += solve_time;
1073            self.restore_iteration_limits();
1074            return Ok(self.extract_solution_view(solve_time));
1075        }
1076
1077        // Check for a definitive terminal status (not a retry-able error).
1078        // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
1079        // on numerically difficult LPs with wide coefficient ranges. The retry
1080        // escalation (especially presolve in the core sequence) often resolves these.
1081        // ITERATION_LIMIT from the initial attempt is retryable — the retry
1082        // sequence uses different strategies that may converge faster.
1083        // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
1084        // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
1085        // in edge cases. Retry level 0 (cold restart) recovers from this.
1086        // Wall-clock > 15s is also retryable — detects stuck initial solves.
1087        let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
1088        let initial_retryable = is_unbounded
1089            || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
1090            || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
1091            || solve_time > 15.0;
1092        if !initial_retryable {
1093            if let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time) {
1094                self.restore_iteration_limits();
1095                self.stats.failure_count += 1;
1096                return Err(terminal_err);
1097            }
1098        }
1099
1100        // Delegate to the retry escalation method (restores limits internally).
1101        match self.retry_escalation(is_unbounded) {
1102            Ok(outcome) => {
1103                self.stats.retry_count += outcome.attempts;
1104                self.stats.success_count += 1;
1105                self.stats.total_iterations += outcome.iterations;
1106                self.stats.total_solve_time_seconds += outcome.solve_time;
1107                self.stats.retry_level_histogram[outcome.level as usize] += 1;
1108                Ok(self.extract_solution_view(outcome.solve_time))
1109            }
1110            Err((attempts, err)) => {
1111                self.stats.retry_count += attempts;
1112                self.stats.failure_count += 1;
1113                Err(err)
1114            }
1115        }
1116    }
1117
1118    fn reset(&mut self) {
1119        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer. `cobre_highs_clear_solver`
1120        // discards the cached basis and factorization. HiGHS preserves the model data
1121        // internally, but Cobre's `reset` contract requires `load_model` before the
1122        // next solve — enforced by setting `has_model = false`.
1123        let status = unsafe { ffi::cobre_highs_clear_solver(self.handle) };
1124        debug_assert_ne!(
1125            status,
1126            ffi::HIGHS_STATUS_ERROR,
1127            "cobre_highs_clear_solver failed — HiGHS internal state may be inconsistent"
1128        );
1129        // Force `load_model` to be called before the next solve.
1130        self.num_cols = 0;
1131        self.num_rows = 0;
1132        self.has_model = false;
1133        // Intentionally do NOT zero `self.stats` -- statistics accumulate for the
1134        // lifetime of the instance (per trait contract, SS4.3).
1135    }
1136
1137    fn get_basis(&mut self, out: &mut crate::types::Basis) {
1138        assert!(
1139            self.has_model,
1140            "get_basis called without a loaded model — call load_model first"
1141        );
1142
1143        out.col_status.resize(self.num_cols, 0);
1144        out.row_status.resize(self.num_rows, 0);
1145
1146        // SAFETY:
1147        // - `self.handle` is a valid, non-null HiGHS pointer.
1148        // - `out.col_status` has been resized to `num_cols` entries above.
1149        // - `out.row_status` has been resized to `num_rows` entries above.
1150        // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
1151        let get_status = unsafe {
1152            ffi::cobre_highs_get_basis(
1153                self.handle,
1154                out.col_status.as_mut_ptr(),
1155                out.row_status.as_mut_ptr(),
1156            )
1157        };
1158
1159        assert_ne!(
1160            get_status,
1161            ffi::HIGHS_STATUS_ERROR,
1162            "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
1163        );
1164    }
1165
1166    fn solve_with_basis(
1167        &mut self,
1168        basis: &crate::types::Basis,
1169    ) -> Result<crate::types::SolutionView<'_>, SolverError> {
1170        assert!(
1171            self.has_model,
1172            "solve_with_basis called without a loaded model — call load_model first"
1173        );
1174        assert!(
1175            basis.col_status.len() == self.num_cols,
1176            "basis column count {} does not match LP column count {}",
1177            basis.col_status.len(),
1178            self.num_cols
1179        );
1180
1181        // Track every call as a basis offer for diagnostics.
1182        self.stats.basis_offered += 1;
1183
1184        // Copy raw i32 codes directly into the pre-allocated buffers — no enum
1185        // translation. Zero-copy warm-start path.
1186        self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
1187
1188        // Handle dimension mismatch for dynamic cuts:
1189        // - Fewer rows than LP: extend with BASIC.
1190        // - More rows than LP: truncate (extra entries ignored).
1191        let basis_rows = basis.row_status.len();
1192        let lp_rows = self.num_rows;
1193        let copy_len = basis_rows.min(lp_rows);
1194        self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
1195        if lp_rows > basis_rows {
1196            self.basis_row_i32[basis_rows..lp_rows].fill(ffi::HIGHS_BASIS_STATUS_BASIC);
1197        }
1198
1199        // Attempt to install the basis in HiGHS.
1200        // SAFETY:
1201        // - `self.handle` is a valid, non-null HiGHS pointer.
1202        // - `basis_col_i32` has been sized to at least `num_cols` in `load_model`.
1203        // - `basis_row_i32` has been sized to at least `num_rows` in `load_model`/`add_rows`.
1204        // - We pass exactly `num_cols` col entries and `num_rows` row entries.
1205        let basis_set_start = Instant::now();
1206        let set_status = unsafe {
1207            ffi::cobre_highs_set_basis(
1208                self.handle,
1209                self.basis_col_i32.as_ptr(),
1210                self.basis_row_i32.as_ptr(),
1211            )
1212        };
1213        self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1214
1215        // Basis rejection tracking: fall back to cold-start and track for diagnostics.
1216        if set_status == ffi::HIGHS_STATUS_ERROR {
1217            self.stats.basis_rejections += 1;
1218            debug_assert!(false, "raw basis rejected; falling back to cold-start");
1219        }
1220
1221        // Delegate to solve() which handles retry escalation and statistics updates.
1222        self.solve()
1223    }
1224
1225    fn statistics(&self) -> SolverStatistics {
1226        self.stats.clone()
1227    }
1228}
1229
1230/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1231///
1232/// Gated behind the `test-support` feature. The raw handle is intentionally not
1233/// part of the public API — callers use these methods to configure time/iteration
1234/// limits before a solve without going through the safe wrapper.
1235#[cfg(feature = "test-support")]
1236impl HighsSolver {
1237    /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1238    ///
1239    /// # Safety
1240    ///
1241    /// The returned pointer is valid for the lifetime of `self`. The caller must
1242    /// not store the pointer beyond that lifetime, must not call
1243    /// `cobre_highs_destroy` on it, and must not alias it across threads.
1244    #[must_use]
1245    pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1246        self.handle
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::HighsSolver;
1253    use crate::{
1254        SolverInterface,
1255        types::{Basis, RowBatch, StageTemplate},
1256    };
1257
1258    // Shared LP fixture from Solver Interface Testing SS1.1:
1259    // 3 variables, 2 structural constraints, 3 non-zeros.
1260    //
1261    //   min  0*x0 + 1*x1 + 50*x2
1262    //   s.t. x0            = 6   (state-fixing)
1263    //        2*x0 + x2     = 14  (power balance)
1264    //   x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1265    //
1266    // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1267    //   col_starts  = [0, 2, 2, 3]
1268    //   row_indices = [0, 1, 1]
1269    //   values      = [1.0, 2.0, 1.0]
1270    fn make_fixture_stage_template() -> StageTemplate {
1271        StageTemplate {
1272            num_cols: 3,
1273            num_rows: 2,
1274            num_nz: 3,
1275            col_starts: vec![0_i32, 2, 2, 3],
1276            row_indices: vec![0_i32, 1, 1],
1277            values: vec![1.0, 2.0, 1.0],
1278            col_lower: vec![0.0, 0.0, 0.0],
1279            col_upper: vec![10.0, f64::INFINITY, 8.0],
1280            objective: vec![0.0, 1.0, 50.0],
1281            row_lower: vec![6.0, 14.0],
1282            row_upper: vec![6.0, 14.0],
1283            n_state: 1,
1284            n_transfer: 0,
1285            n_dual_relevant: 1,
1286            n_hydro: 1,
1287            max_par_order: 0,
1288            col_scale: Vec::new(),
1289            row_scale: Vec::new(),
1290        }
1291    }
1292
1293    // Benders cut fixture from Solver Interface Testing SS1.2:
1294    // Cut 1: -5*x0 + x1 >= 20  (col_indices [0,1], values [-5, 1])
1295    // Cut 2:  3*x0 + x1 >= 80  (col_indices [0,1], values [ 3, 1])
1296    fn make_fixture_row_batch() -> RowBatch {
1297        RowBatch {
1298            num_rows: 2,
1299            row_starts: vec![0_i32, 2, 4],
1300            col_indices: vec![0_i32, 1, 0, 1],
1301            values: vec![-5.0, 1.0, 3.0, 1.0],
1302            row_lower: vec![20.0, 80.0],
1303            row_upper: vec![f64::INFINITY, f64::INFINITY],
1304        }
1305    }
1306
1307    #[test]
1308    fn test_highs_solver_create_and_name() {
1309        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1310        assert_eq!(solver.name(), "HiGHS");
1311        // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1312    }
1313
1314    #[test]
1315    fn test_highs_solver_send_bound() {
1316        fn assert_send<T: Send>() {}
1317        assert_send::<HighsSolver>();
1318    }
1319
1320    #[test]
1321    fn test_highs_solver_statistics_initial() {
1322        let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1323        let stats = solver.statistics();
1324        assert_eq!(stats.solve_count, 0);
1325        assert_eq!(stats.success_count, 0);
1326        assert_eq!(stats.failure_count, 0);
1327        assert_eq!(stats.total_iterations, 0);
1328        assert_eq!(stats.retry_count, 0);
1329        assert_eq!(stats.total_solve_time_seconds, 0.0);
1330    }
1331
1332    #[test]
1333    fn test_highs_load_model_updates_dimensions() {
1334        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1335        let template = make_fixture_stage_template();
1336
1337        solver.load_model(&template);
1338
1339        assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1340        assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1341        assert_eq!(
1342            solver.col_value.len(),
1343            3,
1344            "col_value buffer must be resized to num_cols"
1345        );
1346        assert_eq!(
1347            solver.col_dual.len(),
1348            3,
1349            "col_dual buffer must be resized to num_cols"
1350        );
1351        assert_eq!(
1352            solver.row_value.len(),
1353            2,
1354            "row_value buffer must be resized to num_rows"
1355        );
1356        assert_eq!(
1357            solver.row_dual.len(),
1358            2,
1359            "row_dual buffer must be resized to num_rows"
1360        );
1361    }
1362
1363    #[test]
1364    fn test_highs_add_rows_updates_dimensions() {
1365        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1366        let template = make_fixture_stage_template();
1367        let cuts = make_fixture_row_batch();
1368
1369        solver.load_model(&template);
1370        solver.add_rows(&cuts);
1371
1372        // 2 structural rows + 2 cut rows = 4
1373        assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1374        assert_eq!(
1375            solver.row_dual.len(),
1376            4,
1377            "row_dual buffer must be resized to 4 after add_rows"
1378        );
1379        assert_eq!(
1380            solver.row_value.len(),
1381            4,
1382            "row_value buffer must be resized to 4 after add_rows"
1383        );
1384        // Columns unchanged
1385        assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1386    }
1387
1388    #[test]
1389    fn test_highs_set_row_bounds_no_panic() {
1390        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1391        let template = make_fixture_stage_template();
1392        solver.load_model(&template);
1393
1394        // Patch row 0 to equality at 4.0. Must complete without panic.
1395        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1396    }
1397
1398    #[test]
1399    fn test_highs_set_col_bounds_no_panic() {
1400        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1401        let template = make_fixture_stage_template();
1402        solver.load_model(&template);
1403
1404        // Patch column 1 lower bound to 10.0. Must complete without panic.
1405        solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1406    }
1407
1408    #[test]
1409    fn test_highs_set_bounds_empty_no_panic() {
1410        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1411        let template = make_fixture_stage_template();
1412        solver.load_model(&template);
1413
1414        // Empty patch slices should be short-circuited without any FFI call.
1415        solver.set_row_bounds(&[], &[], &[]);
1416        solver.set_col_bounds(&[], &[], &[]);
1417    }
1418
1419    /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1420    /// Optimal: x0=6, x1=0, x2=2, objective=100.
1421    #[test]
1422    fn test_highs_solve_basic_lp() {
1423        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1424        let template = make_fixture_stage_template();
1425        solver.load_model(&template);
1426
1427        let solution = solver
1428            .solve()
1429            .expect("solve() must succeed on a feasible LP");
1430
1431        assert!(
1432            (solution.objective - 100.0).abs() < 1e-8,
1433            "objective must be 100.0, got {}",
1434            solution.objective
1435        );
1436        assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1437        assert!(
1438            (solution.primal[0] - 6.0).abs() < 1e-8,
1439            "primal[0] (x0) must be 6.0, got {}",
1440            solution.primal[0]
1441        );
1442        assert!(
1443            (solution.primal[1] - 0.0).abs() < 1e-8,
1444            "primal[1] (x1) must be 0.0, got {}",
1445            solution.primal[1]
1446        );
1447        assert!(
1448            (solution.primal[2] - 2.0).abs() < 1e-8,
1449            "primal[2] (x2) must be 2.0, got {}",
1450            solution.primal[2]
1451        );
1452    }
1453
1454    /// SS1.2: after adding two Benders cuts to SS1.1, optimal objective = 162.
1455    /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1456    /// Obj = 0*6 + 1*62 + 50*2 = 162.
1457    #[test]
1458    fn test_highs_solve_with_cuts() {
1459        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1460        let template = make_fixture_stage_template();
1461        let cuts = make_fixture_row_batch();
1462        solver.load_model(&template);
1463        solver.add_rows(&cuts);
1464
1465        let solution = solver
1466            .solve()
1467            .expect("solve() must succeed on a feasible LP with cuts");
1468
1469        assert!(
1470            (solution.objective - 162.0).abs() < 1e-8,
1471            "objective must be 162.0, got {}",
1472            solution.objective
1473        );
1474        assert!(
1475            (solution.primal[0] - 6.0).abs() < 1e-8,
1476            "primal[0] must be 6.0, got {}",
1477            solution.primal[0]
1478        );
1479        assert!(
1480            (solution.primal[1] - 62.0).abs() < 1e-8,
1481            "primal[1] must be 62.0, got {}",
1482            solution.primal[1]
1483        );
1484        assert!(
1485            (solution.primal[2] - 2.0).abs() < 1e-8,
1486            "primal[2] must be 2.0, got {}",
1487            solution.primal[2]
1488        );
1489    }
1490
1491    /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1492    /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1493    #[test]
1494    fn test_highs_solve_after_rhs_patch() {
1495        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1496        let template = make_fixture_stage_template();
1497        let cuts = make_fixture_row_batch();
1498        solver.load_model(&template);
1499        solver.add_rows(&cuts);
1500
1501        // Patch row 0 (x0=6 equality) to x0=4.
1502        solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1503
1504        let solution = solver
1505            .solve()
1506            .expect("solve() must succeed after RHS patch");
1507
1508        assert!(
1509            (solution.objective - 368.0).abs() < 1e-8,
1510            "objective must be 368.0, got {}",
1511            solution.objective
1512        );
1513    }
1514
1515    /// After two successful solves, statistics must reflect both.
1516    #[test]
1517    fn test_highs_solve_statistics_increment() {
1518        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1519        let template = make_fixture_stage_template();
1520        solver.load_model(&template);
1521
1522        solver.solve().expect("first solve must succeed");
1523        solver.solve().expect("second solve must succeed");
1524
1525        let stats = solver.statistics();
1526        assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1527        assert_eq!(stats.success_count, 2, "success_count must be 2");
1528        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1529        assert!(
1530            stats.total_iterations > 0,
1531            "total_iterations must be positive"
1532        );
1533    }
1534
1535    /// After `reset()`, statistics counters must be unchanged.
1536    #[test]
1537    fn test_highs_reset_preserves_stats() {
1538        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1539        let template = make_fixture_stage_template();
1540        solver.load_model(&template);
1541        solver.solve().expect("solve must succeed");
1542
1543        let stats_before = solver.statistics();
1544        assert_eq!(
1545            stats_before.solve_count, 1,
1546            "solve_count must be 1 before reset"
1547        );
1548
1549        solver.reset();
1550
1551        let stats_after = solver.statistics();
1552        assert_eq!(
1553            stats_after.solve_count, stats_before.solve_count,
1554            "solve_count must be unchanged after reset"
1555        );
1556        assert_eq!(
1557            stats_after.success_count, stats_before.success_count,
1558            "success_count must be unchanged after reset"
1559        );
1560        assert_eq!(
1561            stats_after.total_iterations, stats_before.total_iterations,
1562            "total_iterations must be unchanged after reset"
1563        );
1564    }
1565
1566    /// The first solve must report a positive iteration count.
1567    #[test]
1568    fn test_highs_solve_iterations_positive() {
1569        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1570        let template = make_fixture_stage_template();
1571        solver.load_model(&template);
1572
1573        let solution = solver.solve().expect("solve must succeed");
1574        assert!(
1575            solution.iterations > 0,
1576            "iterations must be positive, got {}",
1577            solution.iterations
1578        );
1579    }
1580
1581    /// The first solve must report a positive wall-clock time.
1582    #[test]
1583    fn test_highs_solve_time_positive() {
1584        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1585        let template = make_fixture_stage_template();
1586        solver.load_model(&template);
1587
1588        let solution = solver.solve().expect("solve must succeed");
1589        assert!(
1590            solution.solve_time_seconds > 0.0,
1591            "solve_time_seconds must be positive, got {}",
1592            solution.solve_time_seconds
1593        );
1594    }
1595
1596    /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1597    /// `failure_count==0`, and `total_iterations` > 0.
1598    #[test]
1599    fn test_highs_solve_statistics_single() {
1600        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1601        let template = make_fixture_stage_template();
1602        solver.load_model(&template);
1603
1604        solver.solve().expect("solve must succeed");
1605
1606        let stats = solver.statistics();
1607        assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1608        assert_eq!(stats.success_count, 1, "success_count must be 1");
1609        assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1610        assert!(
1611            stats.total_iterations > 0,
1612            "total_iterations must be positive after a successful solve"
1613        );
1614    }
1615
1616    /// After `load_model` + `solve()`, `get_basis` must return i32 codes
1617    /// that are all valid `HiGHS` basis status values (0..=4).
1618    #[test]
1619    fn test_get_basis_valid_status_codes() {
1620        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1621        let template = make_fixture_stage_template();
1622        solver.load_model(&template);
1623        solver.solve().expect("solve must succeed before get_basis");
1624
1625        let mut basis = Basis::new(0, 0);
1626        solver.get_basis(&mut basis);
1627
1628        for &code in &basis.col_status {
1629            assert!(
1630                (0..=4).contains(&code),
1631                "col_status code {code} is outside valid HiGHS range 0..=4"
1632            );
1633        }
1634        for &code in &basis.row_status {
1635            assert!(
1636                (0..=4).contains(&code),
1637                "row_status code {code} is outside valid HiGHS range 0..=4"
1638            );
1639        }
1640    }
1641
1642    /// Starting from an empty `Basis`, `get_basis` must resize the output
1643    /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
1644    #[test]
1645    fn test_get_basis_resizes_output() {
1646        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1647        let template = make_fixture_stage_template();
1648        solver.load_model(&template);
1649        solver.solve().expect("solve must succeed before get_basis");
1650
1651        let mut basis = Basis::new(0, 0);
1652        assert_eq!(
1653            basis.col_status.len(),
1654            0,
1655            "initial col_status must be empty"
1656        );
1657        assert_eq!(
1658            basis.row_status.len(),
1659            0,
1660            "initial row_status must be empty"
1661        );
1662
1663        solver.get_basis(&mut basis);
1664
1665        assert_eq!(
1666            basis.col_status.len(),
1667            3,
1668            "col_status must be resized to 3 (num_cols of SS1.1)"
1669        );
1670        assert_eq!(
1671            basis.row_status.len(),
1672            2,
1673            "row_status must be resized to 2 (num_rows of SS1.1)"
1674        );
1675    }
1676
1677    /// Warm-start via `solve_with_basis` on the same LP must reproduce
1678    /// the optimal objective and complete in at most 1 simplex iteration.
1679    #[test]
1680    fn test_solve_with_basis_warm_start() {
1681        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1682        let template = make_fixture_stage_template();
1683        solver.load_model(&template);
1684        solver.solve().expect("cold-start solve must succeed");
1685
1686        let mut basis = Basis::new(0, 0);
1687        solver.get_basis(&mut basis);
1688
1689        // Reload the same model to reset HiGHS internal state.
1690        solver.load_model(&template);
1691        let result = solver
1692            .solve_with_basis(&basis)
1693            .expect("warm-start solve must succeed");
1694
1695        assert!(
1696            (result.objective - 100.0).abs() < 1e-8,
1697            "warm-start objective must be 100.0, got {}",
1698            result.objective
1699        );
1700        assert!(
1701            result.iterations <= 1,
1702            "warm-start from exact basis must use at most 1 iteration, got {}",
1703            result.iterations
1704        );
1705
1706        let stats = solver.statistics();
1707        assert_eq!(
1708            stats.basis_rejections, 0,
1709            "basis_rejections must be 0 when raw basis is accepted, got {}",
1710            stats.basis_rejections
1711        );
1712    }
1713
1714    /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
1715    /// `solve_with_basis` must extend missing rows as Basic and solve correctly.
1716    /// SS1.2 objective with both cuts active is 162.0.
1717    #[test]
1718    fn test_solve_with_basis_dimension_mismatch() {
1719        let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1720        let template = make_fixture_stage_template();
1721        let cuts = make_fixture_row_batch();
1722
1723        // First solve on 2-row LP to capture a 2-row basis.
1724        solver.load_model(&template);
1725        solver.solve().expect("SS1.1 solve must succeed");
1726        let mut basis = Basis::new(0, 0);
1727        solver.get_basis(&mut basis);
1728        assert_eq!(
1729            basis.row_status.len(),
1730            2,
1731            "captured basis must have 2 row statuses"
1732        );
1733
1734        // Reload model and add 2 cuts to get a 4-row LP.
1735        solver.load_model(&template);
1736        solver.add_rows(&cuts);
1737        assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
1738
1739        // Warm-start with the 2-row basis; extra rows are extended as Basic.
1740        let result = solver
1741            .solve_with_basis(&basis)
1742            .expect("solve with dimension-mismatched basis must succeed");
1743
1744        assert!(
1745            (result.objective - 162.0).abs() < 1e-8,
1746            "objective with both cuts active must be 162.0, got {}",
1747            result.objective
1748        );
1749    }
1750}
1751
1752// ─── Research verification tests for non-optimal HiGHS model statuses ────
1753//
1754// These tests verify LP formulations that reliably trigger non-optimal
1755// HiGHS model statuses. They use the raw FFI layer to set options not
1756// exposed through SolverInterface and confirm the expected model status.
1757// Findings are documented in:
1758//   plans/phase-3-solver/epic-08-coverage/research-edge-case-lps.md
1759//
1760// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
1761// heuristic solves it without entering the simplex loop, so time/iteration
1762// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
1763#[cfg(test)]
1764#[allow(clippy::doc_markdown)]
1765mod research_tests_ticket_023 {
1766    // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
1767    // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
1768    // produce ITERATION_LIMIT.
1769
1770    // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
1771    //
1772    // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
1773    // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
1774    //
1775    // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1776    unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
1777        use crate::ffi;
1778        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
1779        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
1780        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
1781        let row_lower: [f64; 2] = [6.0, 14.0];
1782        let row_upper: [f64; 2] = [6.0, 14.0];
1783        let a_start: [i32; 4] = [0, 2, 2, 3];
1784        let a_index: [i32; 3] = [0, 1, 1];
1785        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
1786        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1787        let status = unsafe {
1788            ffi::cobre_highs_pass_lp(
1789                highs,
1790                3,
1791                2,
1792                3,
1793                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1794                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1795                0.0,
1796                col_cost.as_ptr(),
1797                col_lower.as_ptr(),
1798                col_upper.as_ptr(),
1799                row_lower.as_ptr(),
1800                row_upper.as_ptr(),
1801                a_start.as_ptr(),
1802                a_index.as_ptr(),
1803                a_value.as_ptr(),
1804            )
1805        };
1806        assert_eq!(
1807            status,
1808            ffi::HIGHS_STATUS_OK,
1809            "research_load_ss11_lp pass_lp failed"
1810        );
1811    }
1812
1813    /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
1814    ///
1815    /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
1816    /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
1817    /// loop, making limits ineffective. This test documents that behavior.
1818    #[test]
1819    fn test_research_probe_limit_status_on_ss11_lp() {
1820        use crate::ffi;
1821
1822        // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
1823        let highs = unsafe { ffi::cobre_highs_create() };
1824        assert!(!highs.is_null());
1825        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1826        unsafe { research_load_ss11_lp(highs) };
1827        let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1828        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1829        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1830        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1831        eprintln!(
1832            "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1833        );
1834        unsafe { ffi::cobre_highs_destroy(highs) };
1835
1836        // SS1.1 with iteration_limit=0: same result, need a larger LP.
1837        let highs = unsafe { ffi::cobre_highs_create() };
1838        assert!(!highs.is_null());
1839        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1840        unsafe { research_load_ss11_lp(highs) };
1841        let _ = unsafe {
1842            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1843        };
1844        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1845        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1846        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
1847        eprintln!(
1848            "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
1849        );
1850        unsafe { ffi::cobre_highs_destroy(highs) };
1851    }
1852
1853    /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
1854    /// iterations and cannot be solved by crash alone.
1855    ///
1856    /// LP (larger_lp):
1857    ///   min  x0 + x1 + x2 + x3 + x4
1858    ///   s.t. x0 + x1              >= 10
1859    ///        x1 + x2              >= 8
1860    ///        x2 + x3              >= 6
1861    ///        x3 + x4              >= 4
1862    ///   x_i in [0, 100], i = 0..4
1863    ///
1864    /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
1865    ///   col 0: rows [0]       -> a_start[0]=0, a_start[1]=1
1866    ///   col 1: rows [0,1]     -> a_start[2]=3
1867    ///   col 2: rows [1,2]     -> a_start[3]=5
1868    ///   col 3: rows [2,3]     -> a_start[4]=7
1869    ///   col 4: rows [3]       -> a_start[5]=8
1870    ///
1871    /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
1872    unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
1873        use crate::ffi;
1874        let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
1875        let col_lower: [f64; 5] = [0.0; 5];
1876        let col_upper: [f64; 5] = [100.0; 5];
1877        let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
1878        let row_upper: [f64; 4] = [f64::INFINITY; 4];
1879        // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
1880        let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
1881        let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
1882        let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1883        // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
1884        let status = unsafe {
1885            ffi::cobre_highs_pass_lp(
1886                highs,
1887                5,
1888                4,
1889                8,
1890                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1891                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1892                0.0,
1893                col_cost.as_ptr(),
1894                col_lower.as_ptr(),
1895                col_upper.as_ptr(),
1896                row_lower.as_ptr(),
1897                row_upper.as_ptr(),
1898                a_start.as_ptr(),
1899                a_index.as_ptr(),
1900                a_value.as_ptr(),
1901            )
1902        };
1903        assert_eq!(
1904            status,
1905            ffi::HIGHS_STATUS_OK,
1906            "research_load_larger_lp pass_lp failed"
1907        );
1908    }
1909
1910    /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
1911    ///
1912    /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
1913    /// crash. HiGHS checks the time limit at entry to the simplex loop.
1914    /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
1915    ///
1916    /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
1917    /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
1918    #[test]
1919    fn test_research_time_limit_zero_triggers_time_limit_status() {
1920        use crate::ffi;
1921
1922        let highs = unsafe { ffi::cobre_highs_create() };
1923        assert!(!highs.is_null());
1924        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1925        unsafe { research_load_larger_lp(highs) };
1926
1927        let opt_status =
1928            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
1929        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1930
1931        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1932        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1933
1934        eprintln!(
1935            "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1936        );
1937
1938        assert_eq!(
1939            run_status,
1940            ffi::HIGHS_STATUS_WARNING,
1941            "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1942        );
1943        assert_eq!(
1944            model_status,
1945            ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
1946            "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
1947        );
1948
1949        unsafe { ffi::cobre_highs_destroy(highs) };
1950    }
1951
1952    /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
1953    ///
1954    /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
1955    /// the crash phase does not solve it, and the iteration limit check fires.
1956    ///
1957    /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
1958    /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
1959    /// HighsModelStatus::kIterationLimit, iteration count = 0.
1960    #[test]
1961    fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
1962        use crate::ffi;
1963
1964        let highs = unsafe { ffi::cobre_highs_create() };
1965        assert!(!highs.is_null());
1966        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
1967        // Disable presolve so crash cannot solve LP without simplex iterations.
1968        unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
1969        unsafe { research_load_larger_lp(highs) };
1970
1971        let opt_status = unsafe {
1972            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
1973        };
1974        assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
1975
1976        let run_status = unsafe { ffi::cobre_highs_run(highs) };
1977        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
1978
1979        eprintln!(
1980            "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
1981        );
1982
1983        assert_eq!(
1984            run_status,
1985            ffi::HIGHS_STATUS_WARNING,
1986            "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
1987        );
1988        assert_eq!(
1989            model_status,
1990            ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
1991            "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
1992        );
1993
1994        unsafe { ffi::cobre_highs_destroy(highs) };
1995    }
1996
1997    /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
1998    ///
1999    /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
2000    /// and presolve disabled, HiGHS halts at the crash-point solution.
2001    /// Both tests record objective availability for documentation.
2002    #[test]
2003    fn test_research_partial_solution_availability() {
2004        use crate::ffi;
2005
2006        // TIME_LIMIT: observe objective after halting at time check
2007        {
2008            let highs = unsafe { ffi::cobre_highs_create() };
2009            assert!(!highs.is_null());
2010            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2011            unsafe { research_load_larger_lp(highs) };
2012            unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2013            unsafe { ffi::cobre_highs_run(highs) };
2014
2015            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2016            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2017            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
2018            eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
2019            unsafe { ffi::cobre_highs_destroy(highs) };
2020        }
2021
2022        // ITERATION_LIMIT: observe objective at crash point
2023        {
2024            let highs = unsafe { ffi::cobre_highs_create() };
2025            assert!(!highs.is_null());
2026            unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2027            unsafe {
2028                ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
2029            };
2030            unsafe { research_load_larger_lp(highs) };
2031            unsafe {
2032                ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2033            };
2034            unsafe { ffi::cobre_highs_run(highs) };
2035
2036            let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2037            let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2038            assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2039            eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
2040            unsafe { ffi::cobre_highs_destroy(highs) };
2041        }
2042    }
2043
2044    /// Verify restore_default_settings: solve with iteration_limit=0, then solve
2045    /// without limit after restoring defaults. The second solve must succeed optimally.
2046    #[test]
2047    fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
2048        use crate::ffi;
2049
2050        let highs = unsafe { ffi::cobre_highs_create() };
2051        assert!(!highs.is_null());
2052
2053        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2054
2055        // Apply cobre defaults (mirror HighsSolver::new() configuration).
2056        unsafe {
2057            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2058            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2059            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2060            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2061            ffi::cobre_highs_set_double_option(
2062                highs,
2063                c"primal_feasibility_tolerance".as_ptr(),
2064                1e-7,
2065            );
2066            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2067        }
2068
2069        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2070        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2071        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2072        let row_lower: [f64; 2] = [6.0, 14.0];
2073        let row_upper: [f64; 2] = [6.0, 14.0];
2074        let a_start: [i32; 4] = [0, 2, 2, 3];
2075        let a_index: [i32; 3] = [0, 1, 1];
2076        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2077
2078        // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2079        unsafe {
2080            ffi::cobre_highs_pass_lp(
2081                highs,
2082                3,
2083                2,
2084                3,
2085                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2086                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2087                0.0,
2088                col_cost.as_ptr(),
2089                col_lower.as_ptr(),
2090                col_upper.as_ptr(),
2091                row_lower.as_ptr(),
2092                row_upper.as_ptr(),
2093                a_start.as_ptr(),
2094                a_index.as_ptr(),
2095                a_value.as_ptr(),
2096            );
2097            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2098            ffi::cobre_highs_run(highs);
2099        }
2100        let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2101        assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2102
2103        // Restore default settings (mirror restore_default_settings()).
2104        unsafe {
2105            ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2106            ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2107            ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2108            ffi::cobre_highs_set_double_option(
2109                highs,
2110                c"primal_feasibility_tolerance".as_ptr(),
2111                1e-7,
2112            );
2113            ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2114            ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2115            ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2116            // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2117            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2118        }
2119
2120        // Second solve on the same model: must reach OPTIMAL.
2121        unsafe { ffi::cobre_highs_clear_solver(highs) };
2122        unsafe { ffi::cobre_highs_run(highs) };
2123        let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2124        let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2125        assert_eq!(
2126            status2,
2127            ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2128            "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2129        );
2130        assert!(
2131            (obj - 100.0).abs() < 1e-8,
2132            "objective after restore must be 100.0, got {obj}"
2133        );
2134
2135        unsafe { ffi::cobre_highs_destroy(highs) };
2136    }
2137
2138    /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2139    ///
2140    /// This verifies that limiting to a small but non-zero number of iterations
2141    /// also works, providing an alternative formulation for triggering the same status.
2142    #[test]
2143    fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2144        use crate::ffi;
2145
2146        let highs = unsafe { ffi::cobre_highs_create() };
2147        assert!(!highs.is_null());
2148
2149        unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2150
2151        let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2152        let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2153        let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2154        let row_lower: [f64; 2] = [6.0, 14.0];
2155        let row_upper: [f64; 2] = [6.0, 14.0];
2156        let a_start: [i32; 4] = [0, 2, 2, 3];
2157        let a_index: [i32; 3] = [0, 1, 1];
2158        let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2159
2160        unsafe {
2161            ffi::cobre_highs_pass_lp(
2162                highs,
2163                3,
2164                2,
2165                3,
2166                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2167                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2168                0.0,
2169                col_cost.as_ptr(),
2170                col_lower.as_ptr(),
2171                col_upper.as_ptr(),
2172                row_lower.as_ptr(),
2173                row_upper.as_ptr(),
2174                a_start.as_ptr(),
2175                a_index.as_ptr(),
2176                a_value.as_ptr(),
2177            );
2178            ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2179            ffi::cobre_highs_run(highs);
2180        }
2181
2182        let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2183        eprintln!("iteration_limit=1 model_status: {model_status}");
2184        // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2185        // We record both possibilities for the research document.
2186        assert!(
2187            model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2188                || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2189            "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2190        );
2191
2192        unsafe { ffi::cobre_highs_destroy(highs) };
2193    }
2194}