Skip to main content

cobre_solver/
highs.rs

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