cobre_solver/highs.rs
1//! `HiGHS` LP solver backend implementing [`SolverInterface`].
2//!
3//! This module provides [`HighsSolver`], which wraps the `HiGHS` C API through
4//! the FFI layer in `ffi` and implements the full [`SolverInterface`]
5//! contract for iterative LP solving in power system optimization.
6//!
7//! # Thread Safety
8//!
9//! [`HighsSolver`] is `Send` but not `Sync`. The underlying `HiGHS` handle is
10//! exclusively owned; transferring ownership to a worker thread is safe.
11//! Concurrent access from multiple threads is not permitted (`HiGHS`
12//! Implementation SS6.3).
13//!
14//! # Configuration
15//!
16//! The constructor applies performance-tuned defaults (`HiGHS` Implementation
17//! SS4.1): dual simplex, no presolve, no parallelism, suppressed output, and
18//! tight feasibility tolerances. These defaults are optimised for repeated
19//! solves of small-to-medium LPs. Per-run parameters (time limit, iteration
20//! limit) are not set here -- those are applied by the caller before each solve.
21
22use std::ffi::CStr;
23use std::os::raw::c_void;
24use std::time::Instant;
25
26use crate::{
27 DEFAULT_PROFILE_HEURISTIC_SENTINEL, DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL, SolveProfile,
28 SolverInterface, ffi,
29 types::{RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate},
30};
31
32// ─── Default HiGHS configuration ─────────────────────────────────────────────
33//
34// The eight performance-tuned options applied at construction and restored after
35// each retry escalation. Keeping them in a single array eliminates per-option
36// error branches that are structurally impossible to trigger in tests (HiGHS
37// never rejects valid static option names).
38
39/// A typed `HiGHS` option value for the configuration table.
40enum OptionValue {
41 /// String option (`cobre_highs_set_string_option`).
42 Str(&'static CStr),
43 /// Integer option (`cobre_highs_set_int_option`).
44 Int(i32),
45 /// Boolean option (`cobre_highs_set_bool_option`).
46 Bool(i32),
47 /// Double option (`cobre_highs_set_double_option`).
48 Double(f64),
49}
50
51/// A named `HiGHS` option with its default value.
52struct DefaultOption {
53 name: &'static CStr,
54 value: OptionValue,
55}
56
57impl DefaultOption {
58 /// Applies this option to a `HiGHS` handle. Returns the `HiGHS` status code.
59 ///
60 /// # Safety
61 ///
62 /// `handle` must be a valid, non-null pointer from `cobre_highs_create()`.
63 unsafe fn apply(&self, handle: *mut c_void) -> i32 {
64 unsafe {
65 match &self.value {
66 OptionValue::Str(val) => {
67 ffi::cobre_highs_set_string_option(handle, self.name.as_ptr(), val.as_ptr())
68 }
69 OptionValue::Int(val) => {
70 ffi::cobre_highs_set_int_option(handle, self.name.as_ptr(), *val)
71 }
72 OptionValue::Bool(val) => {
73 ffi::cobre_highs_set_bool_option(handle, self.name.as_ptr(), *val)
74 }
75 OptionValue::Double(val) => {
76 ffi::cobre_highs_set_double_option(handle, self.name.as_ptr(), *val)
77 }
78 }
79 }
80 }
81}
82
83/// Performance-tuned default options (`HiGHS` Implementation SS4.1).
84///
85/// These thirteen options are applied at construction and restored after each
86/// retry escalation. The values are tuned for master LPs dominated by many
87/// slack rows that are warm-started across consecutive solves.
88///
89/// `simplex_scale_strategy` is set to 0 (off) because the calling algorithm's
90/// prescaler already normalizes matrix entries toward 1.0; the solver's
91/// internal equilibration scaling is redundant and can distort cost ordering
92/// for large-RHS rows. Retry escalation levels 5+ override this to more
93/// aggressive strategies as a fallback for hard problems.
94///
95/// The last four entries diverge from `HiGHS` defaults to suit warm-started
96/// solves on master LPs with tens of thousands of mostly-slack rows: Devex
97/// pricing avoids per-pivot edge-weight maintenance scaling with row count;
98/// disabling cost perturbation removes wasted cleanup pivots when the basis
99/// is already near-optimal (Bland's rule guards against cycling, and retry
100/// level 0 restores perturbation as a fallback); skipping the initial
101/// condition check eliminates O(m)–O(m²) work whose guarantees are already
102/// provided by the caller's slot-tracked basis reconstruction; row-wise
103/// PRICE wins on hyper-sparse basis-inverse rows; loosening the
104/// rebuild-refactor tolerance skips conservative refactorizations that
105/// aren't earning their keep in this numerically benign regime.
106fn default_options() -> [DefaultOption; 13] {
107 [
108 DefaultOption {
109 name: c"solver",
110 value: OptionValue::Str(c"simplex"),
111 },
112 DefaultOption {
113 name: c"simplex_strategy",
114 value: OptionValue::Int(1), // Dual simplex
115 },
116 DefaultOption {
117 name: c"simplex_scale_strategy",
118 value: OptionValue::Int(0), // Off (prescaler handles scaling)
119 },
120 DefaultOption {
121 name: c"presolve",
122 value: OptionValue::Str(c"off"),
123 },
124 DefaultOption {
125 name: c"parallel",
126 value: OptionValue::Str(c"off"),
127 },
128 DefaultOption {
129 name: c"output_flag",
130 value: OptionValue::Bool(0),
131 },
132 DefaultOption {
133 name: c"primal_feasibility_tolerance",
134 value: OptionValue::Double(1e-9),
135 },
136 DefaultOption {
137 name: c"dual_feasibility_tolerance",
138 value: OptionValue::Double(1e-9),
139 },
140 DefaultOption {
141 name: c"simplex_dual_edge_weight_strategy",
142 value: OptionValue::Int(1), // Devex
143 },
144 DefaultOption {
145 name: c"dual_simplex_cost_perturbation_multiplier",
146 value: OptionValue::Double(0.0), // Off (warm-start regime)
147 },
148 DefaultOption {
149 name: c"simplex_initial_condition_check",
150 value: OptionValue::Bool(0), // Off (caller manages basis quality)
151 },
152 DefaultOption {
153 name: c"simplex_price_strategy",
154 value: OptionValue::Int(1), // Row (hyper-sparse master LPs)
155 },
156 DefaultOption {
157 name: c"rebuild_refactor_solution_error_tolerance",
158 value: OptionValue::Double(1e-6), // Loosened from HiGHS default 1e-8
159 },
160 ]
161}
162
163/// `HiGHS` LP solver instance implementing [`SolverInterface`].
164///
165/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
166/// extraction, scratch i32 index conversion, and statistics accumulation.
167///
168/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
169/// when the instance is dropped.
170///
171/// # Example
172///
173/// ```rust
174/// use cobre_solver::{HighsSolver, SolverInterface};
175///
176/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
177/// assert_eq!(solver.name(), "HiGHS");
178/// ```
179pub struct HighsSolver {
180 /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
181 handle: *mut c_void,
182 /// Pre-allocated buffer for primal column values extracted after each solve.
183 /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
184 col_value: Vec<f64>,
185 /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
186 /// Resized in `load_model`.
187 col_dual: Vec<f64>,
188 /// Pre-allocated buffer for row primal values (constraint activity).
189 /// Resized in `load_model`.
190 row_value: Vec<f64>,
191 /// Pre-allocated buffer for row dual multipliers (shadow prices).
192 /// Resized in `load_model`.
193 row_dual: Vec<f64>,
194 /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
195 /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
196 /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
197 scratch_i32: Vec<i32>,
198 /// Pre-allocated i32 buffer for column basis status codes.
199 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
200 /// Resized in `load_model` to `num_cols`; never shrunk.
201 basis_col_i32: Vec<i32>,
202 /// Pre-allocated i32 buffer for row basis status codes.
203 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
204 /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
205 basis_row_i32: Vec<i32>,
206 /// Scratch buffer for dual-ray extraction in `interpret_terminal_status` (dual).
207 /// Grown lazily to `num_rows` via `resize`; contents are discarded after classification.
208 /// Retained across calls so repeated non-optimal solves do not re-allocate.
209 terminal_status_dual_scratch: Vec<f64>,
210 /// Scratch buffer for primal-ray extraction in `interpret_terminal_status` (primal).
211 /// Grown lazily to `num_cols` via `resize`; contents are discarded after classification.
212 /// Retained across calls so repeated non-optimal solves do not re-allocate.
213 terminal_status_primal_scratch: Vec<f64>,
214 /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
215 num_cols: usize,
216 /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
217 num_rows: usize,
218 /// Whether a model is currently loaded. Set to `true` in `load_model`,
219 /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
220 has_model: bool,
221 /// Accumulated solver statistics. Counters grow monotonically from zero;
222 /// not reset by `reset()`.
223 stats: SolverStatistics,
224 /// Cached solver profile applied by the last `set_*_profile` call.
225 ///
226 /// Initialised to `SolveProfile::default()` at construction, which
227 /// preserves the historical hardcoded behaviour bit-for-bit. Updated by
228 /// the four `SolverInterface` profile setter methods; read by
229 /// `set_iteration_limits` on every solve attempt.
230 current_profile: SolveProfile,
231}
232
233// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
234// handle is not thread-safe for concurrent access, but exclusive ownership is
235// maintained at all times -- exactly one `HighsSolver` instance owns each
236// handle and no shared references to the handle exist. Transferring the
237// `HighsSolver` to another thread (via `Send`) is safe because there is no
238// concurrent access; the new thread has exclusive ownership. `Sync` is
239// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
240unsafe impl Send for HighsSolver {}
241
242/// Outcome of a successful retry escalation in [`HighsSolver::retry_escalation`].
243///
244/// Contains the accumulated attempt count and the solve time / iteration
245/// count from the successful retry level.
246struct RetryOutcome {
247 attempts: u64,
248 solve_time: f64,
249 iterations: u64,
250 /// The retry level (0..11) at which the solve succeeded.
251 level: u32,
252}
253
254impl HighsSolver {
255 /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
256 ///
257 /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
258 /// the thirteen default options defined in `HiGHS` Implementation SS4.1:
259 ///
260 /// | Option | Value | Type |
261 /// |---------------------------------------------|-------------|--------|
262 /// | `solver` | `"simplex"` | string |
263 /// | `simplex_strategy` | `1` | int |
264 /// | `simplex_scale_strategy` | `0` | int |
265 /// | `presolve` | `"off"` | string |
266 /// | `parallel` | `"off"` | string |
267 /// | `output_flag` | `0` | bool |
268 /// | `primal_feasibility_tolerance` | `1e-7` | double |
269 /// | `dual_feasibility_tolerance` | `1e-7` | double |
270 /// | `simplex_dual_edge_weight_strategy` | `1` | int |
271 /// | `dual_simplex_cost_perturbation_multiplier` | `0.0` | double |
272 /// | `simplex_initial_condition_check` | `0` | bool |
273 /// | `simplex_price_strategy` | `1` | int |
274 /// | `rebuild_refactor_solution_error_tolerance` | `1e-6` | double |
275 ///
276 /// # Errors
277 ///
278 /// Returns `Err(SolverError::InternalError { .. })` if:
279 /// - `cobre_highs_create()` returns a null pointer.
280 /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
281 ///
282 /// In both failure cases the `HiGHS` handle is destroyed before returning to
283 /// prevent a resource leak.
284 pub fn new() -> Result<Self, SolverError> {
285 // SAFETY: `cobre_highs_create` is a C function with no preconditions.
286 // It allocates and returns a new `HiGHS` instance, or null on allocation
287 // failure. The returned pointer is opaque and must be passed back to
288 // `HiGHS` API functions.
289 let handle = unsafe { ffi::cobre_highs_create() };
290
291 if handle.is_null() {
292 return Err(SolverError::InternalError {
293 message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
294 error_code: None,
295 });
296 }
297
298 // Apply performance-tuned configuration. On any failure, destroy the
299 // handle before returning to prevent a resource leak.
300 if let Err(e) = Self::apply_default_config(handle) {
301 // SAFETY: `handle` is a valid, non-null pointer obtained from
302 // `cobre_highs_create()` in this same function. It has not been
303 // passed to `cobre_highs_destroy()` yet. After this call, `handle`
304 // must not be used again -- this function returns immediately with Err.
305 unsafe { ffi::cobre_highs_destroy(handle) };
306 return Err(e);
307 }
308
309 Ok(Self {
310 handle,
311 col_value: Vec::new(),
312 col_dual: Vec::new(),
313 row_value: Vec::new(),
314 row_dual: Vec::new(),
315 scratch_i32: Vec::new(),
316 basis_col_i32: Vec::new(),
317 basis_row_i32: Vec::new(),
318 terminal_status_dual_scratch: Vec::new(),
319 terminal_status_primal_scratch: Vec::new(),
320 num_cols: 0,
321 num_rows: 0,
322 has_model: false,
323 stats: SolverStatistics {
324 retry_level_histogram: vec![0u64; 12],
325 ..SolverStatistics::default()
326 },
327 current_profile: SolveProfile::default(),
328 })
329 }
330
331 /// Applies the eight performance-tuned `HiGHS` configuration options.
332 ///
333 /// Called once during construction. Returns `Ok(())` if all options are set
334 /// successfully, or `Err(SolverError::InternalError)` with the failing
335 /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
336 fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
337 for opt in &default_options() {
338 // SAFETY: `handle` is a valid, non-null HiGHS pointer.
339 let status = unsafe { opt.apply(handle) };
340 if status == ffi::HIGHS_STATUS_ERROR {
341 return Err(SolverError::InternalError {
342 message: format!(
343 "HiGHS configuration failed: {}",
344 opt.name.to_str().unwrap_or("?")
345 ),
346 error_code: Some(status),
347 });
348 }
349 }
350 Ok(())
351 }
352
353 /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
354 /// a [`SolutionView`] borrowing directly from those buffers.
355 ///
356 /// The returned view borrows solver-internal buffers and is valid until the next
357 /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
358 /// canonical sign convention (per Solver Abstraction SS8).
359 fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
360 // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
361 let status = unsafe {
362 ffi::cobre_highs_get_solution(
363 self.handle,
364 self.col_value.as_mut_ptr(),
365 self.col_dual.as_mut_ptr(),
366 self.row_value.as_mut_ptr(),
367 self.row_dual.as_mut_ptr(),
368 )
369 };
370 // HiGHS documentation guarantees `cobre_highs_get_solution` returns
371 // non-ERROR status after `OPTIMAL` model status; this is a
372 // debug-build-only invariant check.
373 debug_assert_ne!(
374 status,
375 ffi::HIGHS_STATUS_ERROR,
376 "cobre_highs_get_solution failed after optimal solve; HiGHS invariant violation"
377 );
378
379 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
380 let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
381
382 // SAFETY: iteration count is non-negative so cast is safe.
383 #[allow(clippy::cast_sign_loss)]
384 let iterations =
385 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
386
387 SolutionView {
388 objective,
389 primal: &self.col_value[..self.num_cols],
390 dual: &self.row_dual[..self.num_rows],
391 reduced_costs: &self.col_dual[..self.num_cols],
392 iterations,
393 solve_time_seconds,
394 }
395 }
396
397 /// Re-applies the current profile's feasibility tolerances to the `HiGHS` instance.
398 ///
399 /// Called immediately after `restore_default_settings()` in the retry-escalation
400 /// finalization path so that `HiGHS` state and `current_profile` remain in sync.
401 /// `restore_default_settings` resets the tolerances to the hardcoded table values
402 /// (1e-9); this helper layers the caller's profile values on top.
403 ///
404 /// The iteration limits are not re-applied here because `restore_iteration_limits`
405 /// always follows immediately and sets them to `i32::MAX` (unconstrained for the
406 /// post-retry default-attempt path).
407 fn apply_profile_tolerances(&mut self) {
408 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained from
409 // `cobre_highs_create()`. Option names are static C string literals with no
410 // retained pointer after the call returns.
411 unsafe {
412 ffi::cobre_highs_set_double_option(
413 self.handle,
414 c"primal_feasibility_tolerance".as_ptr(),
415 self.current_profile.primal_feasibility_tolerance,
416 );
417 ffi::cobre_highs_set_double_option(
418 self.handle,
419 c"dual_feasibility_tolerance".as_ptr(),
420 self.current_profile.dual_feasibility_tolerance,
421 );
422 }
423 }
424
425 /// Restores default options after retry escalation.
426 ///
427 /// Status codes are checked via `debug_assert!` to catch programming
428 /// errors during development (e.g., invalid option name). In release
429 /// builds, failures are silently ignored since we are already on the
430 /// recovery path.
431 fn restore_default_settings(&mut self) {
432 for opt in &default_options() {
433 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
434 let status = unsafe { opt.apply(self.handle) };
435 debug_assert_eq!(
436 status,
437 ffi::HIGHS_STATUS_OK,
438 "restore_default_settings: option {:?} failed with status {status}",
439 opt.name,
440 );
441 }
442 }
443
444 /// Runs the solver once and returns the raw `HiGHS` model status.
445 fn run_once(&mut self) -> i32 {
446 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
447 let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
448 if run_status == ffi::HIGHS_STATUS_ERROR {
449 return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
450 }
451 // SAFETY: same.
452 unsafe { ffi::cobre_highs_get_model_status(self.handle) }
453 }
454
455 /// Sets per-solve iteration limits before a `run_once()` call.
456 ///
457 /// Simplex cap: if `current_profile.simplex_iteration_limit` equals
458 /// [`DEFAULT_PROFILE_HEURISTIC_SENTINEL`] (`0`), the historical heuristic
459 /// `max(100_000, 50 × num_cols)` is used. Any non-zero profile value is
460 /// applied verbatim (clamped to `i32::MAX` for the FFI call).
461 ///
462 /// IPM cap: if `current_profile.ipm_iteration_limit` equals
463 /// [`DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL`] (`0`), `i32::MAX` is sent to
464 /// `HiGHS` (no cap). Any positive value is applied verbatim (clamped to
465 /// `i32::MAX` for the FFI call). The `Default` value is `10_000`, so
466 /// existing callers see no behavioural change.
467 ///
468 /// **Note on `time_limit`**: `HiGHS` tracks elapsed time cumulatively from
469 /// instance creation, not per-`run()` call — neither `clear_solver()` nor
470 /// option changes reset the internal timer. This makes `time_limit`
471 /// unusable for the scenario-loop pattern (thousands of solves per
472 /// instance). Wall-clock measurement via `Instant` is used instead for
473 /// time-based budget management.
474 fn set_iteration_limits(&mut self) {
475 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
476 let simplex_iter_limit: i32 =
477 if self.current_profile.simplex_iteration_limit == DEFAULT_PROFILE_HEURISTIC_SENTINEL {
478 // Heuristic fallback: scale with LP size to avoid runaway cycling.
479 let heuristic = self.num_cols.saturating_mul(50).max(100_000);
480 // `heuristic` is bounded by `usize::MAX`, but realistic LP sizes
481 // are well below `i32::MAX` (≈2.1 × 10^9 cols). Clamp defensively.
482 (heuristic.min(i32::MAX as usize)) as i32
483 } else {
484 // Profile literal value: apply verbatim, clamped for FFI cast.
485 // `.min(i32::MAX as u32)` ensures the value fits; the cast cannot wrap.
486 #[allow(clippy::cast_possible_wrap)]
487 {
488 (self
489 .current_profile
490 .simplex_iteration_limit
491 .min(i32::MAX as u32)) as i32
492 }
493 };
494
495 // IPM cap: 0 is the "unbounded" sentinel per trait contract; map it to
496 // i32::MAX so HiGHS does not interpret 0 as "no iterations allowed".
497 // Any positive value is applied verbatim (clamped for the FFI cast).
498 #[allow(clippy::cast_possible_wrap)]
499 let ipm_iter_limit: i32 =
500 if self.current_profile.ipm_iteration_limit == DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL {
501 i32::MAX // "unbounded" per trait contract
502 } else {
503 (self
504 .current_profile
505 .ipm_iteration_limit
506 .min(i32::MAX as u32)) as i32
507 };
508
509 // SAFETY: handle is valid non-null HiGHS pointer; option names are
510 // static C strings with no retained pointers.
511 unsafe {
512 ffi::cobre_highs_set_int_option(
513 self.handle,
514 c"simplex_iteration_limit".as_ptr(),
515 simplex_iter_limit,
516 );
517 ffi::cobre_highs_set_int_option(
518 self.handle,
519 c"ipm_iteration_limit".as_ptr(),
520 ipm_iter_limit,
521 );
522 }
523 }
524
525 /// Restores iteration limits to their unconstrained defaults.
526 ///
527 /// Called after `retry_escalation` completes (regardless of outcome).
528 fn restore_iteration_limits(&mut self) {
529 // SAFETY: handle is valid non-null HiGHS pointer.
530 unsafe {
531 ffi::cobre_highs_set_int_option(
532 self.handle,
533 c"simplex_iteration_limit".as_ptr(),
534 i32::MAX,
535 );
536 ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), i32::MAX);
537 }
538 }
539
540 /// Interprets a non-optimal status as a terminal `SolverError`.
541 ///
542 /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
543 /// or `Some(error)` for terminal statuses.
544 fn interpret_terminal_status(
545 &mut self,
546 status: i32,
547 solve_time_seconds: f64,
548 ) -> Option<SolverError> {
549 match status {
550 ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
551 // Caller should have handled optimal before reaching here.
552 None
553 }
554 ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
555 ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
556 // Probe for a dual ray to classify as Infeasible, then a primal
557 // ray to classify as Unbounded. The ray values are not stored in
558 // the error -- only the classification matters.
559 //
560 // `num_rows` and `num_cols` are up-to-date because `load_model`
561 // and `add_rows` always update them before any solve that could
562 // reach this branch. The `resize` below matches the exact count
563 // that HiGHS writes into the buffer.
564 let mut has_dual_ray: i32 = 0;
565 self.terminal_status_dual_scratch.resize(self.num_rows, 0.0);
566 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
567 // `terminal_status_dual_scratch` has been resized to at least
568 // `self.num_rows` elements; HiGHS writes exactly `num_rows` values.
569 let dual_status = unsafe {
570 ffi::cobre_highs_get_dual_ray(
571 self.handle,
572 &raw mut has_dual_ray,
573 self.terminal_status_dual_scratch.as_mut_ptr(),
574 )
575 };
576 if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
577 return Some(SolverError::Infeasible);
578 }
579 let mut has_primal_ray: i32 = 0;
580 self.terminal_status_primal_scratch
581 .resize(self.num_cols, 0.0);
582 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
583 // `terminal_status_primal_scratch` has been resized to at least
584 // `self.num_cols` elements; HiGHS writes exactly `num_cols` values.
585 let primal_status = unsafe {
586 ffi::cobre_highs_get_primal_ray(
587 self.handle,
588 &raw mut has_primal_ray,
589 self.terminal_status_primal_scratch.as_mut_ptr(),
590 )
591 };
592 if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
593 return Some(SolverError::Unbounded);
594 }
595 Some(SolverError::Infeasible)
596 }
597 ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
598 ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
599 elapsed_seconds: solve_time_seconds,
600 }),
601 ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
602 // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
603 #[allow(clippy::cast_sign_loss)]
604 let iterations =
605 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
606 Some(SolverError::IterationLimit { iterations })
607 }
608 ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
609 // Signal to the caller that retry should continue.
610 None
611 }
612 other => Some(SolverError::InternalError {
613 message: format!("HiGHS returned unexpected model status {other}"),
614 error_code: Some(other),
615 }),
616 }
617 }
618
619 /// Converts `usize` indices to `i32` in the internal scratch buffer.
620 ///
621 /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
622 fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
623 if source.len() > self.scratch_i32.len() {
624 self.scratch_i32.resize(source.len(), 0);
625 }
626 for (i, &v) in source.iter().enumerate() {
627 debug_assert!(
628 i32::try_from(v).is_ok(),
629 "usize index {v} overflows i32::MAX at position {i}"
630 );
631 // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
632 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
633 {
634 self.scratch_i32[i] = v as i32;
635 }
636 }
637 &self.scratch_i32[..source.len()]
638 }
639
640 /// Run the 12-level retry escalation when the initial solve fails.
641 ///
642 /// Returns `Ok(RetryOutcome)` when a retry level finds optimal, or
643 /// `Err((attempts, SolverError))` when all levels are exhausted or a
644 /// terminal error is encountered. The caller is responsible for
645 /// updating `self.stats` based on the outcome.
646 ///
647 /// Settings are always restored to defaults before returning (regardless
648 /// of outcome).
649 fn retry_escalation(&mut self, is_unbounded: bool) -> Result<RetryOutcome, (u64, SolverError)> {
650 // 12-level retry escalation (HiGHS Implementation SS3). Organised into
651 // two phases:
652 //
653 // Phase 1 (levels 0-4): Core cumulative sequence. Each level adds one
654 // option on top of the previous state. This proven sequence resolves
655 // the vast majority of retry-recoverable failures.
656 // L0: cold restart
657 // L1: + presolve
658 // L2: + dual simplex
659 // L3: + relaxed tolerances 1e-8
660 // L4: + IPM
661 //
662 // Phase 2 (levels 5-11): Extended strategies. Each level starts from
663 // a clean default state with presolve enabled and a time cap, then
664 // applies a specific combination of scaling, tolerances, and solver
665 // type. These address LPs with extreme coefficient ranges that the
666 // core sequence cannot resolve.
667 //
668 // Wall-clock per-level budgets: 15s (Phase 1), 30s (Phase 2), 60s
669 // (Phase 2 extended). Overall 120s wall-clock budget caps the total.
670 //
671 // HiGHS `time_limit` is NOT used because HiGHS tracks elapsed time
672 // cumulatively from instance creation — neither `clear_solver()` nor
673 // option changes reset the internal timer. Iteration limits provide
674 // the primary per-attempt safeguard; wall-clock budgets provide the
675 // secondary time-based guard.
676 let phase1_wall_budget = 15.0_f64;
677 let phase2_wall_budget = 30.0_f64;
678 let overall_budget = 120.0_f64;
679 let num_retry_levels = 12_u32;
680
681 let retry_start = Instant::now();
682 let mut retry_attempts: u64 = 0;
683 let mut terminal_err: Option<SolverError> = None;
684 let mut found_optimal = false;
685 let mut optimal_time = 0.0_f64;
686 let mut optimal_iterations: u64 = 0;
687 let mut optimal_level = 0_u32;
688
689 for level in 0..num_retry_levels {
690 // Check overall wall-clock budget before starting a new level.
691 if retry_start.elapsed().as_secs_f64() >= overall_budget {
692 break;
693 }
694
695 self.apply_retry_level_options(level);
696
697 retry_attempts += 1;
698
699 let t_retry = Instant::now();
700 let retry_status = self.run_once();
701 let retry_time = t_retry.elapsed().as_secs_f64();
702
703 if retry_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
704 // Capture stats before establishing the borrow.
705 // SAFETY: handle is valid non-null HiGHS pointer.
706 #[allow(clippy::cast_sign_loss)]
707 let iters =
708 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
709 found_optimal = true;
710 optimal_time = retry_time;
711 optimal_iterations = iters;
712 optimal_level = level;
713 break;
714 }
715
716 // UNBOUNDED and ITERATION_LIMIT during retry continue to the next
717 // level: UNBOUNDED may be spurious (presolve resolves it);
718 // ITERATION_LIMIT means this strategy is cycling but another may
719 // converge. Wall-clock budget exceeded also continues (strategy
720 // too slow). Other terminal statuses (INFEASIBLE) stop immediately.
721 let level_budget = if level <= 4 {
722 phase1_wall_budget
723 } else {
724 phase2_wall_budget
725 };
726 let budget_exceeded = retry_time > level_budget;
727 let retryable = retry_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED
728 || retry_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
729 || budget_exceeded;
730 if !retryable {
731 if let Some(e) = self.interpret_terminal_status(retry_status, retry_time) {
732 terminal_err = Some(e);
733 break;
734 }
735 }
736 // Still SOLVE_ERROR, UNKNOWN, UNBOUNDED, ITERATION_LIMIT, or
737 // wall-clock exceeded -- continue to next level.
738 }
739
740 // Restore default settings and safeguard limits unconditionally.
741 // `restore_default_settings()` covers the 13 defaults (including the
742 // hardcoded 1e-9 tolerance values). `apply_profile_tolerances()` then
743 // re-applies the caller's profile tolerances on top, keeping HiGHS
744 // state and `current_profile` in sync (design §5.5). Retry-only options
745 // and safeguard limits need explicit reset.
746 self.restore_default_settings();
747 self.apply_profile_tolerances();
748 self.restore_iteration_limits();
749 unsafe {
750 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), 0);
751 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), 0);
752 }
753
754 if found_optimal {
755 return Ok(RetryOutcome {
756 attempts: retry_attempts,
757 solve_time: optimal_time,
758 iterations: optimal_iterations,
759 level: optimal_level,
760 });
761 }
762
763 Err((
764 retry_attempts,
765 terminal_err.unwrap_or_else(|| {
766 // All 12 retry levels exhausted or overall budget exceeded.
767 if is_unbounded {
768 SolverError::Unbounded
769 } else {
770 SolverError::NumericalDifficulty {
771 message:
772 "HiGHS failed to reach optimality after all retry escalation levels"
773 .to_string(),
774 }
775 }
776 }),
777 ))
778 }
779
780 /// Apply `HiGHS` options for a specific retry escalation level.
781 ///
782 /// Phase 1 (levels 0-4) is cumulative: each level adds options on top of
783 /// the previous state. Both phases apply `time_limit` and iteration limits
784 /// as safeguards against hanging on hard LPs.
785 ///
786 /// Phase 2 (levels 5-11) starts fresh each time with its own time limit.
787 ///
788 /// # Safety (internal)
789 ///
790 /// All FFI calls use `self.handle` which is a valid non-null `HiGHS` pointer.
791 /// Option names and values are static C strings with no retained pointers.
792 fn apply_retry_level_options(&mut self, level: u32) {
793 match level {
794 // -- Phase 1: Core cumulative sequence (levels 0-4) ---------------
795 //
796 // Level 0: cold restart (clear solver state) and re-enable the
797 // dual-simplex cost perturbation. The default configuration runs
798 // with perturbation off (see `DUAL_SIMPLEX_COST_PERTURBATION_MULTIPLIER`)
799 // for warm-start performance, which can stall on degenerate vertices;
800 // restoring the `HiGHS` default of `1.0` is the cheapest first-line
801 // intervention against cycling. Persists through levels 1-4 because
802 // Phase 1 is cumulative.
803 0 => {
804 unsafe {
805 ffi::cobre_highs_clear_solver(self.handle);
806 ffi::cobre_highs_set_double_option(
807 self.handle,
808 c"dual_simplex_cost_perturbation_multiplier".as_ptr(),
809 1.0,
810 );
811 }
812 self.set_iteration_limits();
813 }
814 // Level 1: + presolve.
815 1 => unsafe {
816 ffi::cobre_highs_set_string_option(
817 self.handle,
818 c"presolve".as_ptr(),
819 c"on".as_ptr(),
820 );
821 },
822 // Level 2: + dual simplex.
823 // Cumulative: presolve + dual simplex.
824 2 => unsafe {
825 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
826 },
827 // Level 3: + relaxed tolerances 1e-8.
828 // Cumulative: presolve + dual simplex + relaxed tolerances.
829 // Apply max(level_default, profile_value) so a loose profile is
830 // not inadvertently tightened by the retry ladder (design §5.5).
831 3 => {
832 let level_default = 1e-8_f64;
833 let applied_primal =
834 level_default.max(self.current_profile.primal_feasibility_tolerance);
835 let applied_dual =
836 level_default.max(self.current_profile.dual_feasibility_tolerance);
837 // SAFETY: handle is valid non-null HiGHS pointer; option names
838 // are static C strings with no retained pointers after the call.
839 unsafe {
840 ffi::cobre_highs_set_double_option(
841 self.handle,
842 c"primal_feasibility_tolerance".as_ptr(),
843 applied_primal,
844 );
845 ffi::cobre_highs_set_double_option(
846 self.handle,
847 c"dual_feasibility_tolerance".as_ptr(),
848 applied_dual,
849 );
850 }
851 }
852 // Level 4: + IPM.
853 // Cumulative: presolve + relaxed tolerances + IPM.
854 4 => unsafe {
855 ffi::cobre_highs_set_string_option(
856 self.handle,
857 c"solver".as_ptr(),
858 c"ipm".as_ptr(),
859 );
860 },
861
862 // -- Phase 2: Extended strategies (levels 5-11) -------------------
863 // Each level starts from a clean default state with presolve
864 // and iteration limits, then applies specific options.
865 _ => self.apply_extended_retry_options(level),
866 }
867 }
868
869 /// Apply Phase 2 extended retry strategy options for levels 5-11.
870 ///
871 /// Each level starts from restored defaults with presolve and iteration
872 /// limits, then applies level-specific scaling, tolerance, and solver
873 /// options. Wall-clock budgets are managed by the caller.
874 fn apply_extended_retry_options(&mut self, level: u32) {
875 self.restore_default_settings();
876 self.set_iteration_limits();
877 // SAFETY: handle is valid non-null HiGHS pointer; option names/values
878 // are static C strings; no retained pointers after call.
879 unsafe {
880 ffi::cobre_highs_set_string_option(self.handle, c"presolve".as_ptr(), c"on".as_ptr());
881 }
882 match level {
883 5 => unsafe {
884 ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 3);
885 },
886 6 => unsafe {
887 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
888 ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 4);
889 },
890 7 => {
891 // Apply max(level_default, profile_value) — design §5.5.
892 let level_default = 1e-8_f64;
893 let applied_primal =
894 level_default.max(self.current_profile.primal_feasibility_tolerance);
895 let applied_dual =
896 level_default.max(self.current_profile.dual_feasibility_tolerance);
897 // SAFETY: handle is valid non-null HiGHS pointer; option names
898 // are static C strings with no retained pointers after the call.
899 unsafe {
900 ffi::cobre_highs_set_int_option(
901 self.handle,
902 c"simplex_scale_strategy".as_ptr(),
903 3,
904 );
905 ffi::cobre_highs_set_double_option(
906 self.handle,
907 c"primal_feasibility_tolerance".as_ptr(),
908 applied_primal,
909 );
910 ffi::cobre_highs_set_double_option(
911 self.handle,
912 c"dual_feasibility_tolerance".as_ptr(),
913 applied_dual,
914 );
915 }
916 }
917 8 => unsafe {
918 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
919 },
920 9 => unsafe {
921 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
922 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
923 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
924 },
925 10 => {
926 // Apply max(level_default, profile_value) — design §5.5.
927 let level_default = 1e-7_f64;
928 let applied_primal =
929 level_default.max(self.current_profile.primal_feasibility_tolerance);
930 let applied_dual =
931 level_default.max(self.current_profile.dual_feasibility_tolerance);
932 // SAFETY: handle is valid non-null HiGHS pointer; option names
933 // are static C strings with no retained pointers after the call.
934 unsafe {
935 ffi::cobre_highs_set_int_option(
936 self.handle,
937 c"user_objective_scale".as_ptr(),
938 -13,
939 );
940 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -8);
941 ffi::cobre_highs_set_double_option(
942 self.handle,
943 c"primal_feasibility_tolerance".as_ptr(),
944 applied_primal,
945 );
946 ffi::cobre_highs_set_double_option(
947 self.handle,
948 c"dual_feasibility_tolerance".as_ptr(),
949 applied_dual,
950 );
951 }
952 }
953 11 => {
954 // Apply max(level_default, profile_value) — design §5.5.
955 let level_default = 1e-7_f64;
956 let applied_primal =
957 level_default.max(self.current_profile.primal_feasibility_tolerance);
958 let applied_dual =
959 level_default.max(self.current_profile.dual_feasibility_tolerance);
960 // SAFETY: handle is valid non-null HiGHS pointer; option names
961 // are static C strings with no retained pointers after the call.
962 unsafe {
963 ffi::cobre_highs_set_string_option(
964 self.handle,
965 c"solver".as_ptr(),
966 c"ipm".as_ptr(),
967 );
968 ffi::cobre_highs_set_int_option(
969 self.handle,
970 c"user_objective_scale".as_ptr(),
971 -10,
972 );
973 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
974 ffi::cobre_highs_set_double_option(
975 self.handle,
976 c"primal_feasibility_tolerance".as_ptr(),
977 applied_primal,
978 );
979 ffi::cobre_highs_set_double_option(
980 self.handle,
981 c"dual_feasibility_tolerance".as_ptr(),
982 applied_dual,
983 );
984 }
985 }
986 _ => unreachable!(),
987 }
988 }
989
990 /// Internal helper: run the simplex and update stats.
991 ///
992 /// Core simplex execution, called after (for warm-start) the basis has been
993 /// installed. `HiGHS` retains its internal simplex basis across consecutive
994 /// `solve_inner` calls on the same LP shape, which is the primary warm-start
995 /// mechanism for the backward pass. No `Highs_clearSolver` call is issued —
996 /// that behavior was removed in commit `25f1351` to recover a 4.7× perf regression.
997 fn solve_inner(&mut self) -> Result<SolutionView<'_>, SolverError> {
998 // Safeguard: apply iteration limits before the initial attempt.
999 // Time limits are NOT set here — HiGHS tracks time cumulatively from
1000 // instance creation, so a per-solve time_limit would fire spuriously
1001 // on long-running solver instances. Instead, wall-clock time is checked
1002 // after run_once() to detect stuck solves.
1003 self.set_iteration_limits();
1004
1005 let t0 = Instant::now();
1006 let model_status = self.run_once();
1007 let solve_time = t0.elapsed().as_secs_f64();
1008
1009 self.stats.solve_count += 1;
1010
1011 if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
1012 // Read iteration count from FFI BEFORE establishing the shared borrow
1013 // via extract_solution_view, so stats can be updated without violating
1014 // the aliasing rules.
1015 // SAFETY: handle is valid non-null HiGHS pointer.
1016 #[allow(clippy::cast_sign_loss)]
1017 let iterations =
1018 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
1019 self.stats.success_count += 1;
1020 self.stats.first_try_successes += 1;
1021 self.stats.total_iterations += iterations;
1022 self.stats.total_solve_time_seconds += solve_time;
1023 self.restore_iteration_limits();
1024 return Ok(self.extract_solution_view(solve_time));
1025 }
1026
1027 // Check for a definitive terminal status (not a retry-able error).
1028 // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
1029 // on numerically difficult LPs with wide coefficient ranges. The retry
1030 // escalation (especially presolve in the core sequence) often resolves these.
1031 // ITERATION_LIMIT from the initial attempt is retryable — the retry
1032 // sequence uses different strategies that may converge faster.
1033 // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
1034 // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
1035 // in edge cases. Retry level 0 (cold restart) recovers from this.
1036 // Wall-clock > 15s is also retryable — detects stuck initial solves.
1037 let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
1038 let initial_retryable = is_unbounded
1039 || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
1040 || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
1041 || solve_time > 15.0;
1042 if !initial_retryable {
1043 if let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time) {
1044 self.restore_iteration_limits();
1045 self.stats.failure_count += 1;
1046 return Err(terminal_err);
1047 }
1048 }
1049
1050 // Delegate to the retry escalation method (restores limits internally).
1051 match self.retry_escalation(is_unbounded) {
1052 Ok(outcome) => {
1053 self.stats.retry_count += outcome.attempts;
1054 self.stats.success_count += 1;
1055 self.stats.total_iterations += outcome.iterations;
1056 self.stats.total_solve_time_seconds += outcome.solve_time;
1057 self.stats.retry_level_histogram[outcome.level as usize] += 1;
1058 Ok(self.extract_solution_view(outcome.solve_time))
1059 }
1060 Err((attempts, err)) => {
1061 self.stats.retry_count += attempts;
1062 self.stats.failure_count += 1;
1063 Err(err)
1064 }
1065 }
1066 }
1067}
1068
1069impl Drop for HighsSolver {
1070 fn drop(&mut self) {
1071 // SAFETY: valid HiGHS pointer from construction, called once per instance.
1072 unsafe { ffi::cobre_highs_destroy(self.handle) };
1073 }
1074}
1075
1076/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
1077///
1078/// This is a free function — no solver instance is required.
1079///
1080/// # Example
1081///
1082/// ```rust
1083/// # #[cfg(feature = "highs")]
1084/// # {
1085/// let v = cobre_solver::highs_version();
1086/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
1087/// # }
1088/// ```
1089#[must_use]
1090pub fn highs_version() -> String {
1091 // SAFETY: These are pure query functions with no arguments. The HiGHS C API
1092 // documents them as safe to call without any prior initialisation; they read
1093 // only compile-time constants embedded in the library.
1094 let major = unsafe { crate::ffi::cobre_highs_version_major() };
1095 let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
1096 let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
1097 format!("{major}.{minor}.{patch}")
1098}
1099
1100impl SolverInterface for HighsSolver {
1101 fn name(&self) -> &'static str {
1102 "HiGHS"
1103 }
1104
1105 fn solver_name_version(&self) -> String {
1106 format!("HiGHS {}", highs_version())
1107 }
1108
1109 fn load_model(&mut self, template: &StageTemplate) {
1110 let t0 = Instant::now();
1111 // SAFETY:
1112 // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
1113 // - All pointer arguments point into owned `Vec` data that remains alive for the
1114 // duration of this call.
1115 // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
1116 // template, alive for the duration of this borrow.
1117 // - All slice lengths match the HiGHS API contract:
1118 // `num_col + 1` for a_start, `num_nz` for a_index and a_value,
1119 // `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
1120 assert!(
1121 i32::try_from(template.num_cols).is_ok(),
1122 "num_cols {} overflows i32: LP exceeds HiGHS API limit",
1123 template.num_cols
1124 );
1125 assert!(
1126 i32::try_from(template.num_rows).is_ok(),
1127 "num_rows {} overflows i32: LP exceeds HiGHS API limit",
1128 template.num_rows
1129 );
1130 assert!(
1131 i32::try_from(template.num_nz).is_ok(),
1132 "num_nz {} overflows i32: LP exceeds HiGHS API limit",
1133 template.num_nz
1134 );
1135 // SAFETY: All three values have been asserted to fit in i32 above.
1136 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1137 let num_col = template.num_cols as i32;
1138 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1139 let num_row = template.num_rows as i32;
1140 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1141 let num_nz = template.num_nz as i32;
1142 let status = unsafe {
1143 ffi::cobre_highs_pass_lp(
1144 self.handle,
1145 num_col,
1146 num_row,
1147 num_nz,
1148 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1149 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1150 0.0, // objective offset
1151 template.objective.as_ptr(),
1152 template.col_lower.as_ptr(),
1153 template.col_upper.as_ptr(),
1154 template.row_lower.as_ptr(),
1155 template.row_upper.as_ptr(),
1156 template.col_starts.as_ptr(),
1157 template.row_indices.as_ptr(),
1158 template.values.as_ptr(),
1159 )
1160 };
1161
1162 assert_ne!(
1163 status,
1164 ffi::HIGHS_STATUS_ERROR,
1165 "cobre_highs_pass_lp failed with status {status}"
1166 );
1167
1168 self.num_cols = template.num_cols;
1169 self.num_rows = template.num_rows;
1170 self.has_model = true;
1171
1172 // Resize solution extraction buffers to match the new LP dimensions.
1173 // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
1174 self.col_value.resize(self.num_cols, 0.0);
1175 self.col_dual.resize(self.num_cols, 0.0);
1176 self.row_value.resize(self.num_rows, 0.0);
1177 self.row_dual.resize(self.num_rows, 0.0);
1178
1179 // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
1180 // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
1181 self.basis_col_i32.resize(self.num_cols, 0);
1182 self.basis_row_i32.resize(self.num_rows, 0);
1183 self.stats.total_load_model_time_seconds += t0.elapsed().as_secs_f64();
1184 self.stats.load_model_count += 1;
1185 }
1186
1187 fn add_rows(&mut self, rows: &RowBatch) {
1188 assert!(
1189 i32::try_from(rows.num_rows).is_ok(),
1190 "rows.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
1191 rows.num_rows
1192 );
1193 assert!(
1194 i32::try_from(rows.col_indices.len()).is_ok(),
1195 "rows nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
1196 rows.col_indices.len()
1197 );
1198 // SAFETY: Both values have been asserted to fit in i32 above.
1199 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1200 let num_new_row = rows.num_rows as i32;
1201 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1202 let num_new_nz = rows.col_indices.len() as i32;
1203
1204 // SAFETY:
1205 // - `self.handle` is a valid, non-null HiGHS pointer.
1206 // - All pointer arguments point into owned data alive for the duration of this call.
1207 // - `rows.row_starts` and `rows.col_indices` are `Vec<i32>` owned by the RowBatch,
1208 // alive for the duration of this borrow.
1209 // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
1210 // `num_rows` for lower/upper bounds.
1211 let status = unsafe {
1212 ffi::cobre_highs_add_rows(
1213 self.handle,
1214 num_new_row,
1215 rows.row_lower.as_ptr(),
1216 rows.row_upper.as_ptr(),
1217 num_new_nz,
1218 rows.row_starts.as_ptr(),
1219 rows.col_indices.as_ptr(),
1220 rows.values.as_ptr(),
1221 )
1222 };
1223
1224 assert_ne!(
1225 status,
1226 ffi::HIGHS_STATUS_ERROR,
1227 "cobre_highs_add_rows failed with status {status}"
1228 );
1229
1230 self.num_rows += rows.num_rows;
1231
1232 // Grow row-indexed solution extraction buffers to cover the new rows.
1233 self.row_value.resize(self.num_rows, 0.0);
1234 self.row_dual.resize(self.num_rows, 0.0);
1235
1236 // Grow basis row i32 buffer to cover the new rows.
1237 self.basis_row_i32.resize(self.num_rows, 0);
1238 }
1239
1240 fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1241 assert!(
1242 indices.len() == lower.len() && indices.len() == upper.len(),
1243 "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1244 indices.len(),
1245 lower.len(),
1246 upper.len()
1247 );
1248 if indices.is_empty() {
1249 return;
1250 }
1251
1252 assert!(
1253 i32::try_from(indices.len()).is_ok(),
1254 "set_row_bounds: indices.len() {} overflows i32",
1255 indices.len()
1256 );
1257 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1258 let num_entries = indices.len() as i32;
1259
1260 let t0 = Instant::now();
1261 // SAFETY:
1262 // - `self.handle` is a valid, non-null HiGHS pointer.
1263 // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
1264 // alive for `'self`. Pointer is used immediately in the FFI call.
1265 // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1266 // - `num_entries` equals the lengths of all three arrays.
1267 let status = unsafe {
1268 ffi::cobre_highs_change_rows_bounds_by_set(
1269 self.handle,
1270 num_entries,
1271 self.convert_to_i32_scratch(indices).as_ptr(),
1272 lower.as_ptr(),
1273 upper.as_ptr(),
1274 )
1275 };
1276
1277 assert_ne!(
1278 status,
1279 ffi::HIGHS_STATUS_ERROR,
1280 "cobre_highs_change_rows_bounds_by_set failed with status {status}"
1281 );
1282 self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1283 }
1284
1285 fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1286 assert!(
1287 indices.len() == lower.len() && indices.len() == upper.len(),
1288 "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1289 indices.len(),
1290 lower.len(),
1291 upper.len()
1292 );
1293 if indices.is_empty() {
1294 return;
1295 }
1296
1297 assert!(
1298 i32::try_from(indices.len()).is_ok(),
1299 "set_col_bounds: indices.len() {} overflows i32",
1300 indices.len()
1301 );
1302 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1303 let num_entries = indices.len() as i32;
1304
1305 let t0 = Instant::now();
1306 // SAFETY:
1307 // - `self.handle` is a valid, non-null HiGHS pointer.
1308 // - Converted indices point into `self.scratch_i32`, alive for `'self`.
1309 // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1310 // - `num_entries` equals the lengths of all three arrays.
1311 let status = unsafe {
1312 ffi::cobre_highs_change_cols_bounds_by_set(
1313 self.handle,
1314 num_entries,
1315 self.convert_to_i32_scratch(indices).as_ptr(),
1316 lower.as_ptr(),
1317 upper.as_ptr(),
1318 )
1319 };
1320
1321 assert_ne!(
1322 status,
1323 ffi::HIGHS_STATUS_ERROR,
1324 "cobre_highs_change_cols_bounds_by_set failed with status {status}"
1325 );
1326 self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1327 }
1328
1329 /// # Preconditions
1330 ///
1331 /// When `basis` is `Some(b)`, the caller must size
1332 /// `b.row_status` to exactly `self.num_rows` (the current LP
1333 /// row count). Callers that grow the LP by adding rows are
1334 /// responsible for reconciling their basis to the new row
1335 /// count before invoking this method.
1336 fn solve(
1337 &mut self,
1338 basis: Option<&crate::types::Basis>,
1339 ) -> Result<SolutionView<'_>, SolverError> {
1340 assert!(
1341 self.has_model,
1342 "solve called without a loaded model — call load_model first"
1343 );
1344
1345 if let Some(basis) = basis {
1346 assert!(
1347 basis.col_status.len() == self.num_cols,
1348 "basis column count {} does not match LP column count {}",
1349 basis.col_status.len(),
1350 self.num_cols
1351 );
1352 debug_assert!(
1353 basis.row_status.len() >= self.num_rows,
1354 "solve(Some(&basis)): basis.row_status.len() ({}) < self.num_rows ({}); \
1355 callers introducing new rows must reconcile basis (e.g. extend with \
1356 NONBASIC_AT_LOWER for fresh inequality rows) before calling solve. \
1357 The defensive BASIC padding below is incorrect for inequality slacks.",
1358 basis.row_status.len(),
1359 self.num_rows
1360 );
1361
1362 // Track every warm-start call as a basis offer for diagnostics.
1363 self.stats.basis_offered += 1;
1364
1365 // Copy raw i32 codes directly into the pre-allocated buffers — no enum
1366 // translation. Zero-copy warm-start path.
1367 self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
1368
1369 // Precondition: the caller must size `basis.row_status` to
1370 // exactly `self.num_rows`. The production caller reconciles
1371 // the basis size to the current row count before invoking
1372 // `solve(Some(&basis))`, so `basis_rows == lp_rows` always
1373 // holds in practice.
1374 //
1375 // For defensive robustness if a future caller offers a
1376 // mismatched basis:
1377 // - `basis_rows < lp_rows`: pad missing tail rows with BASIC.
1378 // This is incorrect for newly added inequality rows, whose
1379 // slacks should be non-basic at the appropriate bound;
1380 // callers introducing new rows must reconcile the basis
1381 // themselves before calling solve.
1382 // - `basis_rows > lp_rows`: truncate the trailing entries.
1383 // The solver ignores any basis entry beyond `num_rows`.
1384 let basis_rows = basis.row_status.len();
1385 let lp_rows = self.num_rows;
1386 let copy_len = basis_rows.min(lp_rows);
1387 self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
1388 if lp_rows > basis_rows {
1389 self.basis_row_i32[basis_rows..lp_rows].fill(ffi::HIGHS_BASIS_STATUS_BASIC);
1390 }
1391
1392 // SAFETY:
1393 // - `self.handle` is a valid, non-null HiGHS pointer obtained from
1394 // `cobre_highs_create()` and kept alive by `HighsSolver`.
1395 // - `basis_col_i32` was sized to `num_cols` in `load_model` and grown in
1396 // `add_rows`; the slice written above covers exactly `num_cols` entries.
1397 // - `basis_row_i32` was sized to `num_rows` in `load_model` and grown in
1398 // `add_rows`; the slice written above covers exactly `num_rows` entries
1399 // (with missing rows extended to BASIC).
1400 let basis_set_start = Instant::now();
1401 let set_status = unsafe {
1402 ffi::cobre_highs_set_basis_non_alien(
1403 self.handle,
1404 self.basis_col_i32.as_ptr(),
1405 self.basis_row_i32.as_ptr(),
1406 )
1407 };
1408 if set_status == ffi::HIGHS_STATUS_ERROR {
1409 // Non-alien rejected: the offered basis failed
1410 // `isBasisConsistent` (total_basic != num_row).
1411 // Count the rejection and surface it as a hard error.
1412 self.stats.basis_consistency_failures += 1;
1413 // Count basic entries from the already-populated buffers.
1414 //
1415 // `usize` -> `i64` is lossless for any basis that fits in memory:
1416 // realistic LP sizes are bounded well below 2^63.
1417 #[allow(clippy::cast_possible_wrap)]
1418 let col_basic = self.basis_col_i32[..self.num_cols]
1419 .iter()
1420 .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1421 .count() as i64;
1422 #[allow(clippy::cast_possible_wrap)]
1423 let row_basic = self.basis_row_i32[..self.num_rows]
1424 .iter()
1425 .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1426 .count() as i64;
1427 // Accumulate the elapsed time even on early return.
1428 self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1429 #[allow(clippy::cast_possible_wrap)]
1430 return Err(SolverError::BasisInconsistent {
1431 num_row: self.num_rows as i64,
1432 total_basic: col_basic + row_basic,
1433 col_basic,
1434 row_basic,
1435 });
1436 }
1437 self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1438 }
1439
1440 // Basis is installed (warm path) or not needed (cold path); run the simplex.
1441 // HiGHS retains its internal basis across consecutive solves on the same
1442 // LP shape, giving the backward pass ~15x fewer simplex iterations on
1443 // repeat solves at the same stage/opening.
1444 self.solve_inner()
1445 }
1446
1447 fn get_basis(&mut self, out: &mut crate::types::Basis) {
1448 assert!(
1449 self.has_model,
1450 "get_basis called without a loaded model — call load_model first"
1451 );
1452
1453 out.col_status.resize(self.num_cols, 0);
1454 out.row_status.resize(self.num_rows, 0);
1455
1456 // SAFETY:
1457 // - `self.handle` is a valid, non-null HiGHS pointer.
1458 // - `out.col_status` has been resized to `num_cols` entries above.
1459 // - `out.row_status` has been resized to `num_rows` entries above.
1460 // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
1461 let get_status = unsafe {
1462 ffi::cobre_highs_get_basis(
1463 self.handle,
1464 out.col_status.as_mut_ptr(),
1465 out.row_status.as_mut_ptr(),
1466 )
1467 };
1468
1469 assert_ne!(
1470 get_status,
1471 ffi::HIGHS_STATUS_ERROR,
1472 "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
1473 );
1474 }
1475
1476 fn statistics(&self) -> SolverStatistics {
1477 self.stats.clone()
1478 }
1479
1480 fn record_reconstruction_stats(&mut self) {
1481 self.stats.basis_reconstructions += 1;
1482 }
1483
1484 /// Configures the primal feasibility tolerance on the underlying `HiGHS`
1485 /// instance and caches the value in `current_profile`.
1486 ///
1487 /// Subsequent solves (default attempt and any retry level that does not
1488 /// override this tolerance) use `value` as the primal tolerance until
1489 /// another setter call changes it. After retry escalation completes, the
1490 /// solver automatically re-applies the profile tolerances via
1491 /// `apply_profile_tolerances` — callers do not need to re-call this method.
1492 ///
1493 /// This is not a hot-path method; it is called by `ProfiledSolver::set_profile`
1494 /// only when the corresponding field changes.
1495 fn set_primal_feasibility_tolerance(&mut self, value: f64) {
1496 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained
1497 // from `cobre_highs_create()`. The option name is a static C string
1498 // literal with no retained pointer after the call returns.
1499 unsafe {
1500 ffi::cobre_highs_set_double_option(
1501 self.handle,
1502 c"primal_feasibility_tolerance".as_ptr(),
1503 value,
1504 );
1505 }
1506 self.current_profile.primal_feasibility_tolerance = value;
1507 }
1508
1509 /// Configures the dual feasibility tolerance on the underlying `HiGHS`
1510 /// instance and caches the value in `current_profile`.
1511 ///
1512 /// Symmetric to [`Self::set_primal_feasibility_tolerance`]; see that
1513 /// method for the full contract.
1514 fn set_dual_feasibility_tolerance(&mut self, value: f64) {
1515 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained
1516 // from `cobre_highs_create()`. The option name is a static C string
1517 // literal with no retained pointer after the call returns.
1518 unsafe {
1519 ffi::cobre_highs_set_double_option(
1520 self.handle,
1521 c"dual_feasibility_tolerance".as_ptr(),
1522 value,
1523 );
1524 }
1525 self.current_profile.dual_feasibility_tolerance = value;
1526 }
1527
1528 /// Caches the per-attempt simplex iteration cap in `current_profile`.
1529 ///
1530 /// No FFI call is issued here. The actual cap is applied by
1531 /// `set_iteration_limits` before each solve attempt: a value of
1532 /// [`DEFAULT_PROFILE_HEURISTIC_SENTINEL`] (`0`) causes the heuristic
1533 /// `num_cols × 50 max 100_000` to be used; any non-zero value is applied
1534 /// verbatim.
1535 ///
1536 /// This is not a hot-path method; it is called by `ProfiledSolver::set_profile`
1537 /// only when the corresponding field changes.
1538 fn set_simplex_iteration_limit_profile(&mut self, value: u32) {
1539 self.current_profile.simplex_iteration_limit = value;
1540 }
1541
1542 /// Caches the per-attempt IPM iteration cap in `current_profile`.
1543 ///
1544 /// No FFI call is issued here. The actual cap is applied by
1545 /// `set_iteration_limits` before each solve attempt. Any positive value is
1546 /// applied verbatim; zero is treated as "unbounded" (no cap).
1547 ///
1548 /// This is not a hot-path method; it is called by `ProfiledSolver::set_profile`
1549 /// only when the corresponding field changes.
1550 fn set_ipm_iteration_limit_profile(&mut self, value: u32) {
1551 self.current_profile.ipm_iteration_limit = value;
1552 }
1553}
1554
1555/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1556///
1557/// Gated behind the `test-support` feature. The raw handle is intentionally not
1558/// part of the public API — callers use these methods to configure time/iteration
1559/// limits before a solve without going through the safe wrapper.
1560#[cfg(feature = "test-support")]
1561impl HighsSolver {
1562 /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1563 ///
1564 /// # Safety
1565 ///
1566 /// The returned pointer is valid for the lifetime of `self`. The caller must
1567 /// not store the pointer beyond that lifetime, must not call
1568 /// `cobre_highs_destroy` on it, and must not alias it across threads.
1569 #[must_use]
1570 pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1571 self.handle
1572 }
1573
1574 /// Invoke `apply_retry_level_options` for a given level.
1575 ///
1576 /// Thin test-support wrapper that exposes the private retry-level applier
1577 /// so integration tests can verify option composition without driving a
1578 /// full solve through the retry ladder.
1579 ///
1580 /// Only levels 0-4 are routed through this method; levels 5-11 delegate to
1581 /// `apply_extended_retry_options_for_test`. Call the appropriate method
1582 /// based on the level you want to test.
1583 pub fn apply_retry_level_options_for_test(&mut self, level: u32) {
1584 self.apply_retry_level_options(level);
1585 }
1586
1587 /// Invoke `apply_extended_retry_options` for a given level (5-11).
1588 ///
1589 /// Thin test-support wrapper that exposes the private extended retry-level
1590 /// applier so integration tests can verify option composition without
1591 /// driving a full solve.
1592 pub fn apply_extended_retry_options_for_test(&mut self, level: u32) {
1593 self.apply_extended_retry_options(level);
1594 }
1595
1596 /// Invoke `restore_default_settings` then `apply_profile_tolerances` in sequence.
1597 ///
1598 /// Mirrors the finalization path in `retry_escalation` so integration tests
1599 /// can verify that profile tolerances survive a defaults-restore without
1600 /// driving a full retry through a failing LP.
1601 pub fn restore_defaults_then_apply_profile_for_test(&mut self) {
1602 self.restore_default_settings();
1603 self.apply_profile_tolerances();
1604 }
1605
1606 /// Read a double-valued `HiGHS` option by name.
1607 ///
1608 /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
1609 /// on success.
1610 #[must_use]
1611 pub fn get_double_option(&self, option: &std::ffi::CStr) -> Option<f64> {
1612 let mut out = 0.0_f64;
1613 // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
1614 // null-terminated C string borrowed for the duration of the call;
1615 // `out` is stack-allocated and written by HiGHS on success.
1616 let status = unsafe {
1617 ffi::cobre_highs_get_double_option(self.handle, option.as_ptr(), &raw mut out)
1618 };
1619 if status == ffi::HIGHS_STATUS_ERROR {
1620 None
1621 } else {
1622 Some(out)
1623 }
1624 }
1625
1626 /// Read an integer-valued `HiGHS` option by name.
1627 ///
1628 /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
1629 /// on success.
1630 #[must_use]
1631 pub fn get_int_option(&self, option: &std::ffi::CStr) -> Option<i32> {
1632 let mut out = 0_i32;
1633 // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
1634 // null-terminated C string borrowed for the duration of the call;
1635 // `out` is stack-allocated and written by HiGHS on success.
1636 let status =
1637 unsafe { ffi::cobre_highs_get_int_option(self.handle, option.as_ptr(), &raw mut out) };
1638 if status == ffi::HIGHS_STATUS_ERROR {
1639 None
1640 } else {
1641 Some(out)
1642 }
1643 }
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648 use super::HighsSolver;
1649 use crate::{
1650 SolverInterface,
1651 types::{Basis, RowBatch, StageTemplate},
1652 };
1653
1654 // Shared LP fixture from Solver Interface Testing SS1.1:
1655 // 3 variables, 2 structural constraints, 3 non-zeros.
1656 //
1657 // min 0*x0 + 1*x1 + 50*x2
1658 // s.t. x0 = 6 (state-fixing)
1659 // 2*x0 + x2 = 14 (power balance)
1660 // x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1661 //
1662 // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1663 // col_starts = [0, 2, 2, 3]
1664 // row_indices = [0, 1, 1]
1665 // values = [1.0, 2.0, 1.0]
1666 fn make_fixture_stage_template() -> StageTemplate {
1667 StageTemplate {
1668 num_cols: 3,
1669 num_rows: 2,
1670 num_nz: 3,
1671 col_starts: vec![0_i32, 2, 2, 3],
1672 row_indices: vec![0_i32, 1, 1],
1673 values: vec![1.0, 2.0, 1.0],
1674 col_lower: vec![0.0, 0.0, 0.0],
1675 col_upper: vec![10.0, f64::INFINITY, 8.0],
1676 objective: vec![0.0, 1.0, 50.0],
1677 row_lower: vec![6.0, 14.0],
1678 row_upper: vec![6.0, 14.0],
1679 n_state: 1,
1680 n_transfer: 0,
1681 n_dual_relevant: 1,
1682 n_hydro: 1,
1683 max_par_order: 0,
1684 col_scale: Vec::new(),
1685 row_scale: Vec::new(),
1686 }
1687 }
1688
1689 // Valid-inequality fixture from Solver Interface Testing SS1.2:
1690 // Row 1: -5*x0 + x1 >= 20 (col_indices [0,1], values [-5, 1])
1691 // Row 2: 3*x0 + x1 >= 80 (col_indices [0,1], values [ 3, 1])
1692 fn make_fixture_row_batch() -> RowBatch {
1693 RowBatch {
1694 num_rows: 2,
1695 row_starts: vec![0_i32, 2, 4],
1696 col_indices: vec![0_i32, 1, 0, 1],
1697 values: vec![-5.0, 1.0, 3.0, 1.0],
1698 row_lower: vec![20.0, 80.0],
1699 row_upper: vec![f64::INFINITY, f64::INFINITY],
1700 }
1701 }
1702
1703 #[test]
1704 fn test_highs_solver_create_and_name() {
1705 let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1706 assert_eq!(solver.name(), "HiGHS");
1707 // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1708 }
1709
1710 #[test]
1711 fn test_highs_solver_send_bound() {
1712 fn assert_send<T: Send>() {}
1713 assert_send::<HighsSolver>();
1714 }
1715
1716 #[test]
1717 fn test_highs_solver_statistics_initial() {
1718 let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1719 let stats = solver.statistics();
1720 assert_eq!(stats.solve_count, 0);
1721 assert_eq!(stats.success_count, 0);
1722 assert_eq!(stats.failure_count, 0);
1723 assert_eq!(stats.total_iterations, 0);
1724 assert_eq!(stats.retry_count, 0);
1725 assert_eq!(stats.total_solve_time_seconds, 0.0);
1726 }
1727
1728 #[test]
1729 fn test_highs_load_model_updates_dimensions() {
1730 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1731 let template = make_fixture_stage_template();
1732
1733 solver.load_model(&template);
1734
1735 assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1736 assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1737 assert_eq!(
1738 solver.col_value.len(),
1739 3,
1740 "col_value buffer must be resized to num_cols"
1741 );
1742 assert_eq!(
1743 solver.col_dual.len(),
1744 3,
1745 "col_dual buffer must be resized to num_cols"
1746 );
1747 assert_eq!(
1748 solver.row_value.len(),
1749 2,
1750 "row_value buffer must be resized to num_rows"
1751 );
1752 assert_eq!(
1753 solver.row_dual.len(),
1754 2,
1755 "row_dual buffer must be resized to num_rows"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_highs_add_rows_updates_dimensions() {
1761 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1762 let template = make_fixture_stage_template();
1763 let cuts = make_fixture_row_batch();
1764
1765 solver.load_model(&template);
1766 solver.add_rows(&cuts);
1767
1768 // 2 structural rows + 2 appended rows = 4
1769 assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1770 assert_eq!(
1771 solver.row_dual.len(),
1772 4,
1773 "row_dual buffer must be resized to 4 after add_rows"
1774 );
1775 assert_eq!(
1776 solver.row_value.len(),
1777 4,
1778 "row_value buffer must be resized to 4 after add_rows"
1779 );
1780 // Columns unchanged
1781 assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1782 }
1783
1784 #[test]
1785 fn test_highs_set_row_bounds_no_panic() {
1786 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1787 let template = make_fixture_stage_template();
1788 solver.load_model(&template);
1789
1790 // Patch row 0 to equality at 4.0. Must complete without panic.
1791 solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1792 }
1793
1794 #[test]
1795 fn test_highs_set_col_bounds_no_panic() {
1796 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1797 let template = make_fixture_stage_template();
1798 solver.load_model(&template);
1799
1800 // Patch column 1 lower bound to 10.0. Must complete without panic.
1801 solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1802 }
1803
1804 #[test]
1805 fn test_highs_set_bounds_empty_no_panic() {
1806 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1807 let template = make_fixture_stage_template();
1808 solver.load_model(&template);
1809
1810 // Empty patch slices should be short-circuited without any FFI call.
1811 solver.set_row_bounds(&[], &[], &[]);
1812 solver.set_col_bounds(&[], &[], &[]);
1813 }
1814
1815 /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1816 /// Optimal: x0=6, x1=0, x2=2, objective=100.
1817 #[test]
1818 fn test_highs_solve_basic_lp() {
1819 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1820 let template = make_fixture_stage_template();
1821 solver.load_model(&template);
1822
1823 let solution = solver
1824 .solve(None)
1825 .expect("solve() must succeed on a feasible LP");
1826
1827 assert!(
1828 (solution.objective - 100.0).abs() < 1e-8,
1829 "objective must be 100.0, got {}",
1830 solution.objective
1831 );
1832 assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1833 assert!(
1834 (solution.primal[0] - 6.0).abs() < 1e-8,
1835 "primal[0] (x0) must be 6.0, got {}",
1836 solution.primal[0]
1837 );
1838 assert!(
1839 (solution.primal[1] - 0.0).abs() < 1e-8,
1840 "primal[1] (x1) must be 0.0, got {}",
1841 solution.primal[1]
1842 );
1843 assert!(
1844 (solution.primal[2] - 2.0).abs() < 1e-8,
1845 "primal[2] (x2) must be 2.0, got {}",
1846 solution.primal[2]
1847 );
1848 }
1849
1850 /// SS1.2: after adding two valid inequalities to SS1.1, optimal objective = 162.
1851 /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1852 /// Obj = 0*6 + 1*62 + 50*2 = 162.
1853 #[test]
1854 fn test_highs_solve_with_cuts() {
1855 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1856 let template = make_fixture_stage_template();
1857 let cuts = make_fixture_row_batch();
1858 solver.load_model(&template);
1859 solver.add_rows(&cuts);
1860
1861 let solution = solver
1862 .solve(None)
1863 .expect("solve() must succeed on a feasible LP with cuts");
1864
1865 assert!(
1866 (solution.objective - 162.0).abs() < 1e-8,
1867 "objective must be 162.0, got {}",
1868 solution.objective
1869 );
1870 assert!(
1871 (solution.primal[0] - 6.0).abs() < 1e-8,
1872 "primal[0] must be 6.0, got {}",
1873 solution.primal[0]
1874 );
1875 assert!(
1876 (solution.primal[1] - 62.0).abs() < 1e-8,
1877 "primal[1] must be 62.0, got {}",
1878 solution.primal[1]
1879 );
1880 assert!(
1881 (solution.primal[2] - 2.0).abs() < 1e-8,
1882 "primal[2] must be 2.0, got {}",
1883 solution.primal[2]
1884 );
1885 }
1886
1887 /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1888 /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1889 #[test]
1890 fn test_highs_solve_after_rhs_patch() {
1891 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1892 let template = make_fixture_stage_template();
1893 let cuts = make_fixture_row_batch();
1894 solver.load_model(&template);
1895 solver.add_rows(&cuts);
1896
1897 // Patch row 0 (x0=6 equality) to x0=4.
1898 solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1899
1900 let solution = solver
1901 .solve(None)
1902 .expect("solve() must succeed after RHS patch");
1903
1904 assert!(
1905 (solution.objective - 368.0).abs() < 1e-8,
1906 "objective must be 368.0, got {}",
1907 solution.objective
1908 );
1909 }
1910
1911 /// After two successful solves, statistics must reflect both.
1912 #[test]
1913 fn test_highs_solve_statistics_increment() {
1914 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1915 let template = make_fixture_stage_template();
1916 solver.load_model(&template);
1917
1918 solver.solve(None).expect("first solve must succeed");
1919 solver.solve(None).expect("second solve must succeed");
1920
1921 let stats = solver.statistics();
1922 assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1923 assert_eq!(stats.success_count, 2, "success_count must be 2");
1924 assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1925 assert!(
1926 stats.total_iterations > 0,
1927 "total_iterations must be positive"
1928 );
1929 }
1930
1931 /// After a cold solve, statistics counters must reflect the single solve.
1932 #[test]
1933 fn test_highs_solve_preserves_stats() {
1934 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1935 let template = make_fixture_stage_template();
1936 solver.load_model(&template);
1937 solver.solve(None).expect("solve must succeed");
1938
1939 let stats = solver.statistics();
1940 assert_eq!(
1941 stats.solve_count, 1,
1942 "solve_count must be 1 after one solve"
1943 );
1944 assert_eq!(
1945 stats.success_count, 1,
1946 "success_count must be 1 after one successful solve"
1947 );
1948 assert!(
1949 stats.total_iterations > 0,
1950 "total_iterations must be positive after a successful solve"
1951 );
1952 }
1953
1954 /// The first solve must report a positive iteration count.
1955 #[test]
1956 fn test_highs_solve_iterations_positive() {
1957 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1958 let template = make_fixture_stage_template();
1959 solver.load_model(&template);
1960
1961 let solution = solver.solve(None).expect("solve must succeed");
1962 assert!(
1963 solution.iterations > 0,
1964 "iterations must be positive, got {}",
1965 solution.iterations
1966 );
1967 }
1968
1969 /// The first solve must report a positive wall-clock time.
1970 #[test]
1971 fn test_highs_solve_time_positive() {
1972 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1973 let template = make_fixture_stage_template();
1974 solver.load_model(&template);
1975
1976 let solution = solver.solve(None).expect("solve must succeed");
1977 assert!(
1978 solution.solve_time_seconds > 0.0,
1979 "solve_time_seconds must be positive, got {}",
1980 solution.solve_time_seconds
1981 );
1982 }
1983
1984 /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1985 /// `failure_count==0`, and `total_iterations` > 0.
1986 #[test]
1987 fn test_highs_solve_statistics_single() {
1988 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1989 let template = make_fixture_stage_template();
1990 solver.load_model(&template);
1991
1992 solver.solve(None).expect("solve must succeed");
1993
1994 let stats = solver.statistics();
1995 assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1996 assert_eq!(stats.success_count, 1, "success_count must be 1");
1997 assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1998 assert!(
1999 stats.total_iterations > 0,
2000 "total_iterations must be positive after a successful solve"
2001 );
2002 }
2003
2004 /// After `load_model` + `solve()`, `get_basis` must return i32 codes
2005 /// that are all valid `HiGHS` basis status values (0..=4).
2006 #[test]
2007 fn test_get_basis_valid_status_codes() {
2008 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2009 let template = make_fixture_stage_template();
2010 solver.load_model(&template);
2011 solver
2012 .solve(None)
2013 .expect("solve must succeed before get_basis");
2014
2015 let mut basis = Basis::new(0, 0);
2016 solver.get_basis(&mut basis);
2017
2018 for &code in &basis.col_status {
2019 assert!(
2020 (0..=4).contains(&code),
2021 "col_status code {code} is outside valid HiGHS range 0..=4"
2022 );
2023 }
2024 for &code in &basis.row_status {
2025 assert!(
2026 (0..=4).contains(&code),
2027 "row_status code {code} is outside valid HiGHS range 0..=4"
2028 );
2029 }
2030 }
2031
2032 /// Starting from an empty `Basis`, `get_basis` must resize the output
2033 /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
2034 #[test]
2035 fn test_get_basis_resizes_output() {
2036 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2037 let template = make_fixture_stage_template();
2038 solver.load_model(&template);
2039 solver
2040 .solve(None)
2041 .expect("solve must succeed before get_basis");
2042
2043 let mut basis = Basis::new(0, 0);
2044 assert_eq!(
2045 basis.col_status.len(),
2046 0,
2047 "initial col_status must be empty"
2048 );
2049 assert_eq!(
2050 basis.row_status.len(),
2051 0,
2052 "initial row_status must be empty"
2053 );
2054
2055 solver.get_basis(&mut basis);
2056
2057 assert_eq!(
2058 basis.col_status.len(),
2059 3,
2060 "col_status must be resized to 3 (num_cols of SS1.1)"
2061 );
2062 assert_eq!(
2063 basis.row_status.len(),
2064 2,
2065 "row_status must be resized to 2 (num_rows of SS1.1)"
2066 );
2067 }
2068
2069 /// Warm-start via `solve(Some(&basis))` on the same LP must reproduce
2070 /// the optimal objective and complete in at most 1 simplex iteration.
2071 #[test]
2072 fn test_solve_warm_start_reproduces_cold_objective() {
2073 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2074 let template = make_fixture_stage_template();
2075 solver.load_model(&template);
2076 solver.solve(None).expect("cold-start solve must succeed");
2077
2078 let mut basis = Basis::new(0, 0);
2079 solver.get_basis(&mut basis);
2080
2081 // Reload the same model to reset HiGHS internal state.
2082 solver.load_model(&template);
2083 let result = solver
2084 .solve(Some(&basis))
2085 .expect("warm-start solve must succeed");
2086
2087 assert!(
2088 (result.objective - 100.0).abs() < 1e-8,
2089 "warm-start objective must be 100.0, got {}",
2090 result.objective
2091 );
2092 assert!(
2093 result.iterations <= 1,
2094 "warm-start from exact basis must use at most 1 iteration, got {}",
2095 result.iterations
2096 );
2097
2098 let stats = solver.statistics();
2099 assert_eq!(
2100 stats.basis_consistency_failures, 0,
2101 "basis_consistency_failures must be 0 when raw basis is accepted, got {}",
2102 stats.basis_consistency_failures
2103 );
2104 assert_eq!(
2105 stats.basis_offered, 1,
2106 "basis_offered must be 1 after one warm-start call"
2107 );
2108 }
2109
2110 /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
2111 /// `solve(Some(&basis))` must extend missing rows as Basic and solve correctly.
2112 /// SS1.2 objective with both cuts active is 162.0.
2113 ///
2114 /// This test exercises the defensive BASIC-padding fallback path,
2115 /// which the production caller never hits because it reconciles the
2116 /// basis to the LP row count before invoking `solve`. The
2117 /// `debug_assert!` in `solve` would fire on this fallback path, so
2118 /// the test runs only when `debug_assertions` is disabled.
2119 #[cfg(not(debug_assertions))]
2120 #[test]
2121 fn test_solve_warm_start_extends_missing_rows_as_basic() {
2122 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2123 let template = make_fixture_stage_template();
2124 let cuts = make_fixture_row_batch();
2125
2126 // First solve on 2-row LP to capture a 2-row basis.
2127 solver.load_model(&template);
2128 solver.solve(None).expect("SS1.1 solve must succeed");
2129 let mut basis = Basis::new(0, 0);
2130 solver.get_basis(&mut basis);
2131 assert_eq!(
2132 basis.row_status.len(),
2133 2,
2134 "captured basis must have 2 row statuses"
2135 );
2136
2137 // Reload model and add 2 cuts to get a 4-row LP.
2138 solver.load_model(&template);
2139 solver.add_rows(&cuts);
2140 assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
2141
2142 // Warm-start with the 2-row basis; extra rows are extended as Basic.
2143 let result = solver
2144 .solve(Some(&basis))
2145 .expect("solve with dimension-mismatched basis must succeed");
2146
2147 assert!(
2148 (result.objective - 162.0).abs() < 1e-8,
2149 "objective with both cuts active must be 162.0, got {}",
2150 result.objective
2151 );
2152 }
2153
2154 /// Non-alien path accepts a self-extracted basis: counter must stay at zero.
2155 ///
2156 /// Solves SS1.1 cold, extracts the optimal basis, reloads the model, and
2157 /// warm-starts via `solve(Some(&basis))`. The non-alien FFI call
2158 /// (`cobre_highs_set_basis_non_alien`) should accept a basis that was just
2159 /// produced by `HiGHS` itself, so `basis_consistency_failures` must not
2160 /// increase.
2161 #[test]
2162 fn test_solve_warm_start_non_alien_success() {
2163 // Arrange
2164 let template = make_fixture_stage_template();
2165 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2166 solver.load_model(&template);
2167 let _ = solver.solve(None).expect("cold-start solve must succeed");
2168 let mut basis = Basis::new(template.num_cols, template.num_rows);
2169 solver.get_basis(&mut basis);
2170
2171 // Reload model so HiGHS internal state is fresh, then warm-start.
2172 solver.load_model(&template);
2173 let before = solver.statistics();
2174
2175 // Act
2176 let _ = solver
2177 .solve(Some(&basis))
2178 .expect("warm-start solve must succeed with self-extracted basis");
2179
2180 // Assert
2181 let after = solver.statistics();
2182 assert_eq!(
2183 after.basis_consistency_failures - before.basis_consistency_failures,
2184 0,
2185 "non-alien path should accept a self-extracted basis; consistency failures delta must be 0"
2186 );
2187 }
2188
2189 /// `solve(Some(&basis))` returns `Err(SolverError::BasisInconsistent)` when given
2190 /// an inconsistent basis instead of silently falling back to the alien setter.
2191 ///
2192 /// Builds a deliberately inconsistent basis (all column statuses set to
2193 /// `HIGHS_BASIS_STATUS_BASIC`, all row statuses `HIGHS_BASIS_STATUS_BASIC`).
2194 /// For the 3-column, 2-row SS1.1 LP this yields 5 basic variables against a
2195 /// rank of 2, which `cobre_highs_set_basis_non_alien` rejects with
2196 /// `HIGHS_STATUS_ERROR`. The error is surfaced as a hard `Err` and
2197 /// `basis_consistency_failures` increments by 1.
2198 ///
2199 /// After the call:
2200 /// - `basis_consistency_failures` increments by 1.
2201 /// - The result is `Err(SolverError::BasisInconsistent { num_row: 2,
2202 /// total_basic: 5, col_basic: 3, row_basic: 2 })`.
2203 #[test]
2204 fn test_solve_warm_start_rejects_inconsistent_basis() {
2205 use crate::ffi;
2206 use crate::types::SolverError;
2207
2208 // Arrange: non-alien setter is now the only warm-start path.
2209 let template = make_fixture_stage_template();
2210 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2211
2212 solver.load_model(&template);
2213
2214 // Build a deliberately inconsistent basis: all BASIC (5 basics, rank 2).
2215 let mut bad_basis = Basis::new(template.num_cols, template.num_rows);
2216 bad_basis
2217 .col_status
2218 .iter_mut()
2219 .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
2220 bad_basis
2221 .row_status
2222 .iter_mut()
2223 .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
2224
2225 let before = solver.statistics();
2226
2227 // Act — convert result to a form that does not borrow `solver`.
2228 // `SolutionView<'_>` borrows `solver`'s internal buffers; calling
2229 // `statistics()` afterwards would overlap borrows. On the error path
2230 // `SolverError` contains no solver references, so mapping Ok → () breaks
2231 // the mutable borrow before the statistics call.
2232 let err_variant: Result<(), SolverError> = solver.solve(Some(&bad_basis)).map(|_| ());
2233
2234 // Assert counters — the mutable borrow from solve(Some(&bad_basis)) is gone.
2235 let after = solver.statistics();
2236 assert_eq!(
2237 after.basis_consistency_failures - before.basis_consistency_failures,
2238 1,
2239 "basis_consistency_failures must increment by 1 for an overcounted basis"
2240 );
2241
2242 // Assert the returned error.
2243 match err_variant {
2244 Err(SolverError::BasisInconsistent {
2245 num_row,
2246 total_basic,
2247 col_basic,
2248 row_basic,
2249 }) => {
2250 assert_eq!(num_row, 2, "num_row must match LP row count");
2251 assert_eq!(total_basic, 5, "total_basic must be col_basic + row_basic");
2252 assert_eq!(col_basic, 3, "col_basic must count BASIC columns");
2253 assert_eq!(row_basic, 2, "row_basic must count BASIC rows");
2254 }
2255 other => panic!(
2256 "expected Err(SolverError::BasisInconsistent {{ num_row: 2, total_basic: 5, \
2257 col_basic: 3, row_basic: 2 }}), got {other:?}"
2258 ),
2259 }
2260 }
2261
2262 /// `terminal_status_dual_scratch` and `terminal_status_primal_scratch` are
2263 /// initialized as empty `Vec`s in the constructor and retain their capacity
2264 /// across repeated `resize` calls, matching the pattern used by
2265 /// `scratch_i32`, `basis_col_i32`, and `basis_row_i32`.
2266 ///
2267 /// This test directly exercises the `resize`-reuse invariant without depending
2268 /// on a specific `HiGHS` model status. The `UNBOUNDED_OR_INFEASIBLE` branch in
2269 /// `interpret_terminal_status` calls `self.terminal_status_dual_scratch.resize(num_rows, 0.0)`;
2270 /// we verify here that repeated `resize` calls grow but never shrink capacity.
2271 ///
2272 /// The LP: 3-column, 2-row SS1.1 fixture. After `load_model`, `num_rows=2` and
2273 /// `num_cols=3`. We simulate two scratch-buffer resize cycles and verify capacity
2274 /// is monotonically non-decreasing.
2275 #[test]
2276 fn interpret_terminal_status_reuses_scratch() {
2277 let template = make_fixture_stage_template();
2278 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2279
2280 // Verify that scratch fields start empty (Vec::new() in constructor).
2281 assert_eq!(
2282 solver.terminal_status_dual_scratch.capacity(),
2283 0,
2284 "dual scratch must start with capacity 0 (Vec::new() in constructor)"
2285 );
2286 assert_eq!(
2287 solver.terminal_status_primal_scratch.capacity(),
2288 0,
2289 "primal scratch must start with capacity 0 (Vec::new() in constructor)"
2290 );
2291
2292 // Load model to establish num_rows=2 and num_cols=3.
2293 solver.load_model(&template);
2294
2295 // Simulate what interpret_terminal_status does in UNBOUNDED_OR_INFEASIBLE branch:
2296 // resize dual scratch to num_rows, primal scratch to num_cols.
2297 solver
2298 .terminal_status_dual_scratch
2299 .resize(solver.num_rows, 0.0);
2300 solver
2301 .terminal_status_primal_scratch
2302 .resize(solver.num_cols, 0.0);
2303
2304 let cap_dual_after_first = solver.terminal_status_dual_scratch.capacity();
2305 let cap_primal_after_first = solver.terminal_status_primal_scratch.capacity();
2306
2307 assert!(
2308 cap_dual_after_first >= solver.num_rows,
2309 "dual scratch capacity {cap_dual_after_first} must be >= num_rows {} after first resize",
2310 solver.num_rows,
2311 );
2312 assert!(
2313 cap_primal_after_first >= solver.num_cols,
2314 "primal scratch capacity {cap_primal_after_first} must be >= num_cols {} after first resize",
2315 solver.num_cols,
2316 );
2317
2318 // Second resize to the same size: capacity must not decrease (heap retained).
2319 solver
2320 .terminal_status_dual_scratch
2321 .resize(solver.num_rows, 0.0);
2322 solver
2323 .terminal_status_primal_scratch
2324 .resize(solver.num_cols, 0.0);
2325
2326 let cap_dual_after_second = solver.terminal_status_dual_scratch.capacity();
2327 let cap_primal_after_second = solver.terminal_status_primal_scratch.capacity();
2328
2329 assert!(
2330 cap_dual_after_second >= cap_dual_after_first,
2331 "dual scratch capacity must not decrease: {cap_dual_after_second} < {cap_dual_after_first}",
2332 );
2333 assert!(
2334 cap_primal_after_second >= cap_primal_after_first,
2335 "primal scratch capacity must not decrease: {cap_primal_after_second} < {cap_primal_after_first}",
2336 );
2337 }
2338
2339 /// Verify that a freshly constructed `HighsSolver` exposes a `current_profile`
2340 /// equal to `SolveProfile::default()`.
2341 ///
2342 /// This ensures that callers that never call any profile setter observe the
2343 /// historical hardcoded behaviour bit-for-bit (§5.3 design parity guarantee).
2344 #[test]
2345 fn new_highs_solver_starts_with_default_profile() {
2346 use crate::SolveProfile;
2347
2348 let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2349 assert_eq!(
2350 solver.current_profile,
2351 SolveProfile::default(),
2352 "current_profile must equal SolveProfile::default() immediately after construction"
2353 );
2354 }
2355}
2356
2357// ─── Research verification tests for non-optimal HiGHS model statuses ────
2358//
2359// These tests verify LP formulations that reliably trigger non-optimal
2360// HiGHS model statuses. They use the raw FFI layer to set options not
2361// exposed through SolverInterface and confirm the expected model status.
2362//
2363// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
2364// heuristic solves it without entering the simplex loop, so time/iteration
2365// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
2366#[cfg(test)]
2367#[allow(clippy::doc_markdown)]
2368mod research_tests {
2369 // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
2370 // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
2371 // produce ITERATION_LIMIT.
2372
2373 // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
2374 //
2375 // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
2376 // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
2377 //
2378 // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2379 unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
2380 use crate::ffi;
2381 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2382 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2383 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2384 let row_lower: [f64; 2] = [6.0, 14.0];
2385 let row_upper: [f64; 2] = [6.0, 14.0];
2386 let a_start: [i32; 4] = [0, 2, 2, 3];
2387 let a_index: [i32; 3] = [0, 1, 1];
2388 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2389 // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2390 let status = unsafe {
2391 ffi::cobre_highs_pass_lp(
2392 highs,
2393 3,
2394 2,
2395 3,
2396 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2397 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2398 0.0,
2399 col_cost.as_ptr(),
2400 col_lower.as_ptr(),
2401 col_upper.as_ptr(),
2402 row_lower.as_ptr(),
2403 row_upper.as_ptr(),
2404 a_start.as_ptr(),
2405 a_index.as_ptr(),
2406 a_value.as_ptr(),
2407 )
2408 };
2409 assert_eq!(
2410 status,
2411 ffi::HIGHS_STATUS_OK,
2412 "research_load_ss11_lp pass_lp failed"
2413 );
2414 }
2415
2416 /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
2417 ///
2418 /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
2419 /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
2420 /// loop, making limits ineffective. This test documents that behavior.
2421 #[test]
2422 fn test_research_probe_limit_status_on_ss11_lp() {
2423 use crate::ffi;
2424
2425 // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
2426 let highs = unsafe { ffi::cobre_highs_create() };
2427 assert!(!highs.is_null());
2428 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2429 unsafe { research_load_ss11_lp(highs) };
2430 let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2431 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2432 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2433 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2434 eprintln!(
2435 "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2436 );
2437 unsafe { ffi::cobre_highs_destroy(highs) };
2438
2439 // SS1.1 with iteration_limit=0: same result, need a larger LP.
2440 let highs = unsafe { ffi::cobre_highs_create() };
2441 assert!(!highs.is_null());
2442 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2443 unsafe { research_load_ss11_lp(highs) };
2444 let _ = unsafe {
2445 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2446 };
2447 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2448 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2449 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2450 eprintln!(
2451 "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2452 );
2453 unsafe { ffi::cobre_highs_destroy(highs) };
2454 }
2455
2456 /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
2457 /// iterations and cannot be solved by crash alone.
2458 ///
2459 /// LP (larger_lp):
2460 /// min x0 + x1 + x2 + x3 + x4
2461 /// s.t. x0 + x1 >= 10
2462 /// x1 + x2 >= 8
2463 /// x2 + x3 >= 6
2464 /// x3 + x4 >= 4
2465 /// x_i in [0, 100], i = 0..4
2466 ///
2467 /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
2468 /// col 0: rows [0] -> a_start[0]=0, a_start[1]=1
2469 /// col 1: rows [0,1] -> a_start[2]=3
2470 /// col 2: rows [1,2] -> a_start[3]=5
2471 /// col 3: rows [2,3] -> a_start[4]=7
2472 /// col 4: rows [3] -> a_start[5]=8
2473 ///
2474 /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2475 unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
2476 use crate::ffi;
2477 let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
2478 let col_lower: [f64; 5] = [0.0; 5];
2479 let col_upper: [f64; 5] = [100.0; 5];
2480 let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
2481 let row_upper: [f64; 4] = [f64::INFINITY; 4];
2482 // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
2483 let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
2484 let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
2485 let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
2486 // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2487 let status = unsafe {
2488 ffi::cobre_highs_pass_lp(
2489 highs,
2490 5,
2491 4,
2492 8,
2493 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2494 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2495 0.0,
2496 col_cost.as_ptr(),
2497 col_lower.as_ptr(),
2498 col_upper.as_ptr(),
2499 row_lower.as_ptr(),
2500 row_upper.as_ptr(),
2501 a_start.as_ptr(),
2502 a_index.as_ptr(),
2503 a_value.as_ptr(),
2504 )
2505 };
2506 assert_eq!(
2507 status,
2508 ffi::HIGHS_STATUS_OK,
2509 "research_load_larger_lp pass_lp failed"
2510 );
2511 }
2512
2513 /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
2514 ///
2515 /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
2516 /// crash. HiGHS checks the time limit at entry to the simplex loop.
2517 /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
2518 ///
2519 /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
2520 /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
2521 #[test]
2522 fn test_research_time_limit_zero_triggers_time_limit_status() {
2523 use crate::ffi;
2524
2525 let highs = unsafe { ffi::cobre_highs_create() };
2526 assert!(!highs.is_null());
2527 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2528 unsafe { research_load_larger_lp(highs) };
2529
2530 let opt_status =
2531 unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2532 assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2533
2534 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2535 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2536
2537 eprintln!(
2538 "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2539 );
2540
2541 assert_eq!(
2542 run_status,
2543 ffi::HIGHS_STATUS_WARNING,
2544 "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2545 );
2546 assert_eq!(
2547 model_status,
2548 ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
2549 "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
2550 );
2551
2552 unsafe { ffi::cobre_highs_destroy(highs) };
2553 }
2554
2555 /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
2556 ///
2557 /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
2558 /// the crash phase does not solve it, and the iteration limit check fires.
2559 ///
2560 /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
2561 /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
2562 /// HighsModelStatus::kIterationLimit, iteration count = 0.
2563 #[test]
2564 fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
2565 use crate::ffi;
2566
2567 let highs = unsafe { ffi::cobre_highs_create() };
2568 assert!(!highs.is_null());
2569 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2570 // Disable presolve so crash cannot solve LP without simplex iterations.
2571 unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
2572 unsafe { research_load_larger_lp(highs) };
2573
2574 let opt_status = unsafe {
2575 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2576 };
2577 assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2578
2579 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2580 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2581
2582 eprintln!(
2583 "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2584 );
2585
2586 assert_eq!(
2587 run_status,
2588 ffi::HIGHS_STATUS_WARNING,
2589 "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2590 );
2591 assert_eq!(
2592 model_status,
2593 ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
2594 "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
2595 );
2596
2597 unsafe { ffi::cobre_highs_destroy(highs) };
2598 }
2599
2600 /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
2601 ///
2602 /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
2603 /// and presolve disabled, HiGHS halts at the crash-point solution.
2604 /// Both tests record objective availability for documentation.
2605 #[test]
2606 fn test_research_partial_solution_availability() {
2607 use crate::ffi;
2608
2609 // TIME_LIMIT: observe objective after halting at time check
2610 {
2611 let highs = unsafe { ffi::cobre_highs_create() };
2612 assert!(!highs.is_null());
2613 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2614 unsafe { research_load_larger_lp(highs) };
2615 unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2616 unsafe { ffi::cobre_highs_run(highs) };
2617
2618 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2619 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2620 assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
2621 eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
2622 unsafe { ffi::cobre_highs_destroy(highs) };
2623 }
2624
2625 // ITERATION_LIMIT: observe objective at crash point
2626 {
2627 let highs = unsafe { ffi::cobre_highs_create() };
2628 assert!(!highs.is_null());
2629 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2630 unsafe {
2631 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
2632 };
2633 unsafe { research_load_larger_lp(highs) };
2634 unsafe {
2635 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2636 };
2637 unsafe { ffi::cobre_highs_run(highs) };
2638
2639 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2640 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2641 assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2642 eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
2643 unsafe { ffi::cobre_highs_destroy(highs) };
2644 }
2645 }
2646
2647 /// Verify restore_default_settings: solve with iteration_limit=0, then solve
2648 /// without limit after restoring defaults. The second solve must succeed optimally.
2649 #[test]
2650 fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
2651 use crate::ffi;
2652
2653 let highs = unsafe { ffi::cobre_highs_create() };
2654 assert!(!highs.is_null());
2655
2656 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2657
2658 // Apply cobre defaults (mirror HighsSolver::new() configuration).
2659 unsafe {
2660 ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2661 ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2662 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2663 ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2664 ffi::cobre_highs_set_double_option(
2665 highs,
2666 c"primal_feasibility_tolerance".as_ptr(),
2667 1e-7,
2668 );
2669 ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2670 }
2671
2672 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2673 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2674 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2675 let row_lower: [f64; 2] = [6.0, 14.0];
2676 let row_upper: [f64; 2] = [6.0, 14.0];
2677 let a_start: [i32; 4] = [0, 2, 2, 3];
2678 let a_index: [i32; 3] = [0, 1, 1];
2679 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2680
2681 // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2682 unsafe {
2683 ffi::cobre_highs_pass_lp(
2684 highs,
2685 3,
2686 2,
2687 3,
2688 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2689 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2690 0.0,
2691 col_cost.as_ptr(),
2692 col_lower.as_ptr(),
2693 col_upper.as_ptr(),
2694 row_lower.as_ptr(),
2695 row_upper.as_ptr(),
2696 a_start.as_ptr(),
2697 a_index.as_ptr(),
2698 a_value.as_ptr(),
2699 );
2700 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2701 ffi::cobre_highs_run(highs);
2702 }
2703 let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2704 assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2705
2706 // Restore default settings (mirror restore_default_settings()).
2707 unsafe {
2708 ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2709 ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2710 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2711 ffi::cobre_highs_set_double_option(
2712 highs,
2713 c"primal_feasibility_tolerance".as_ptr(),
2714 1e-7,
2715 );
2716 ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2717 ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2718 ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2719 // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2720 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2721 }
2722
2723 // Second solve on the same model: must reach OPTIMAL.
2724 unsafe { ffi::cobre_highs_clear_solver(highs) };
2725 unsafe { ffi::cobre_highs_run(highs) };
2726 let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2727 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2728 assert_eq!(
2729 status2,
2730 ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2731 "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2732 );
2733 assert!(
2734 (obj - 100.0).abs() < 1e-8,
2735 "objective after restore must be 100.0, got {obj}"
2736 );
2737
2738 unsafe { ffi::cobre_highs_destroy(highs) };
2739 }
2740
2741 /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2742 ///
2743 /// This verifies that limiting to a small but non-zero number of iterations
2744 /// also works, providing an alternative formulation for triggering the same status.
2745 #[test]
2746 fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2747 use crate::ffi;
2748
2749 let highs = unsafe { ffi::cobre_highs_create() };
2750 assert!(!highs.is_null());
2751
2752 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2753
2754 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2755 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2756 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2757 let row_lower: [f64; 2] = [6.0, 14.0];
2758 let row_upper: [f64; 2] = [6.0, 14.0];
2759 let a_start: [i32; 4] = [0, 2, 2, 3];
2760 let a_index: [i32; 3] = [0, 1, 1];
2761 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2762
2763 unsafe {
2764 ffi::cobre_highs_pass_lp(
2765 highs,
2766 3,
2767 2,
2768 3,
2769 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2770 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2771 0.0,
2772 col_cost.as_ptr(),
2773 col_lower.as_ptr(),
2774 col_upper.as_ptr(),
2775 row_lower.as_ptr(),
2776 row_upper.as_ptr(),
2777 a_start.as_ptr(),
2778 a_index.as_ptr(),
2779 a_value.as_ptr(),
2780 );
2781 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2782 ffi::cobre_highs_run(highs);
2783 }
2784
2785 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2786 eprintln!("iteration_limit=1 model_status: {model_status}");
2787 // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2788 // We record both possibilities for the research document.
2789 assert!(
2790 model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2791 || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2792 "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2793 );
2794
2795 unsafe { ffi::cobre_highs_destroy(highs) };
2796 }
2797
2798 /// Verify that `HighsSolver` correctly maps unbounded and infeasible statuses.
2799 ///
2800 /// With presolve=off and dual simplex (the default `HighsSolver` configuration),
2801 /// HiGHS returns `HIGHS_MODEL_STATUS_UNBOUNDED` (10) for unbounded LPs and
2802 /// `HIGHS_MODEL_STATUS_INFEASIBLE` (8) for infeasible LPs. Both are mapped to
2803 /// the appropriate `SolverError` variants without entering the
2804 /// `UNBOUNDED_OR_INFEASIBLE` probe branch.
2805 ///
2806 /// Note: `HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE` (9) is returned only by
2807 /// IPM (`IpxWrapper.cpp:317`) when it detects dual infeasibility, or when
2808 /// `allow_unbounded_or_infeasible=true` is set with presolve=on. Neither
2809 /// condition occurs in the default `HighsSolver` configuration, so the
2810 /// `UNBOUNDED_OR_INFEASIBLE` branch serves as a safe fallback for retry paths
2811 /// that switch to IPM.
2812 #[test]
2813 fn test_research_verify_non_optimal_highs_status_mapping() {
2814 use super::super::HighsSolver;
2815 use crate::SolverInterface;
2816 use crate::types::SolverError;
2817 use crate::types::StageTemplate;
2818
2819 // Unbounded LP: min -x0 - x1, x0 + x1 >= 1, x0/x1 in [0, +inf).
2820 // With presolve=off and dual simplex, HiGHS returns UNBOUNDED (10).
2821 let unbounded_template = StageTemplate {
2822 num_cols: 2,
2823 num_rows: 1,
2824 num_nz: 2,
2825 col_starts: vec![0_i32, 1, 2],
2826 row_indices: vec![0_i32, 0],
2827 values: vec![1.0, 1.0],
2828 col_lower: vec![0.0, 0.0],
2829 col_upper: vec![f64::INFINITY, f64::INFINITY],
2830 objective: vec![-1.0, -1.0],
2831 row_lower: vec![1.0],
2832 row_upper: vec![f64::INFINITY],
2833 n_state: 1,
2834 n_transfer: 0,
2835 n_dual_relevant: 1,
2836 n_hydro: 0,
2837 max_par_order: 0,
2838 col_scale: Vec::new(),
2839 row_scale: Vec::new(),
2840 };
2841 let mut solver_unb = HighsSolver::new().expect("HighsSolver::new() must succeed");
2842 solver_unb.load_model(&unbounded_template);
2843 let result_unb = solver_unb.solve(None).map(|_| ());
2844 assert!(
2845 matches!(result_unb, Err(SolverError::Unbounded)),
2846 "unbounded LP must return Err(SolverError::Unbounded), got {result_unb:?}"
2847 );
2848
2849 // Infeasible LP: x0 must equal 99 but is bounded to [0, 10].
2850 // HiGHS returns INFEASIBLE (8) directly; mapped to Err(SolverError::Infeasible).
2851 let infeasible_template = StageTemplate {
2852 num_cols: 1,
2853 num_rows: 1,
2854 num_nz: 1,
2855 col_starts: vec![0_i32, 1],
2856 row_indices: vec![0_i32],
2857 values: vec![1.0],
2858 col_lower: vec![0.0],
2859 col_upper: vec![10.0],
2860 objective: vec![0.0],
2861 row_lower: vec![99.0],
2862 row_upper: vec![99.0],
2863 n_state: 1,
2864 n_transfer: 0,
2865 n_dual_relevant: 1,
2866 n_hydro: 0,
2867 max_par_order: 0,
2868 col_scale: Vec::new(),
2869 row_scale: Vec::new(),
2870 };
2871 let mut solver_inf = HighsSolver::new().expect("HighsSolver::new() must succeed");
2872 solver_inf.load_model(&infeasible_template);
2873 let result_inf = solver_inf.solve(None).map(|_| ());
2874 assert!(
2875 matches!(result_inf, Err(SolverError::Infeasible)),
2876 "infeasible LP must return Err(SolverError::Infeasible), got {result_inf:?}"
2877 );
2878 }
2879}